Skip to content
On this page

Synara Custom Plugins

Synara supports an extensible plugin architecture that allows you to add custom importers, indexers, routes, scheduled tasks and server-driven UI.

Plugin Structure

A Synara plugin is a Java/Kotlin project that implements the ISynaraPlugin interface from the plugin-api module.

ISynaraPlugin Interface

interface ISynaraPlugin {
    val id: String
    val name: String
    val apiVersion: Int get() = 1
    val enabled: Boolean get() = true
    val hookGroups: Set<HookGroup> get() = emptySet()

    fun init(context: PluginContext)
    fun getKoinModule(): Module? = null
}

enabled decides whether the plugin is loaded at all — a disabled plugin registers no importers, routes or UI contributions.

hookGroups names the groups of server events the plugin subscribes to, see Hooks. A plugin that subscribes to nothing leaves the default.

IImporter has the same enabled flag plus installed. enabled means "ready to import": the backend is present and authenticated, and it is what the import routing uses to pick a backend. installed means only that the backend itself is available (its binary was found). Importers that need a login report installed as soon as their binary exists and enabled only once a token is present, and their plugin bases enabled on installed — so the plugin still loads while unauthenticated and the importer settings page can offer the login.

Additional capabilities are opt-in through marker interfaces your plugin class can implement as well:

Interface Purpose
IContentSourcePlugin Provide IImporters, IPluginIndexers, IPodcastIndexes and metadata services.
IRoutePlugin Register raw Ktor routes.
IUiPlugin Contribute server-driven UI (see below).

PluginContext

Passed to init. Provides:

  • the logger, storageService, the indexer and importService of the server
  • the library APIs (songLibrary, albumLibrary, artistLibrary, playlistLibrary, imageLibrary)
  • metadataService and lrcLibService
  • the scheduleService for scheduled tasks
  • the hooks of the plugin (see Hooks)
  • apiKeyScopes, ui, settings and i18n (see Server-Driven UI)
  • intake and jobs (see Intake resolvers and Jobs)
  • credentials (see Credentials)

Every member is available to every plugin that loads, whatever apiVersion it declares.

Hooks

The server announces what happens to its data as typed HookEvents. A plugin subscribes with context.hooks.on<E> { event -> } and gets a HookRegistration back, whose cancel() ends the subscription. A subscriber observes: it cannot stop or change the operation it hears about.

class MyPlugin : ISynaraPlugin {
    override val id = "myplugin"
    override val name = "My Plugin"
    override val apiVersion = 4
    override val hookGroups = setOf(HookGroup.LIBRARY, HookGroup.PLAYBACK)

    override fun init(context: PluginContext) {
        context.hooks.on<HookEvent.EntitiesDeleted> { event ->
            if (event.type == EntityType.SONG) forget(event.ids)
        }
        context.hooks.on<HookEvent.NowPlayingChanged> { show(it.userId, it.songId) }
    }
}

Hook groups

context.hooks belongs to the plugin and delivers only the events of the groups the plugin declares in hookGroups. Subscribing to an event of a group that is not declared throws an IllegalArgumentException that names the missing group. Thrown from init, it keeps the plugin from loading, so a missing declaration shows up in the log at startup.

Group Events
HookGroup.LIBRARY EntitiesCreated, EntitiesUpdated, EntityMembersChanged, EntitiesDeleted and EntitiesMerged for songs, albums and artists, LibraryIndexed
HookGroup.PLAYLISTS EntitiesCreated, EntitiesUpdated, EntityMembersChanged, EntitiesDeleted and EntitiesMerged for user playlists, global playlists and collections, PlaylistChanged, CollectionChanged
HookGroup.USER_STATE LikesChanged, TimecodesChanged
HookGroup.PLAYBACK NowPlayingChanged, ListenIngested

The entity events exist in two groups and their type decides: a plugin that declares only LIBRARY and subscribes to EntitiesCreated gets songs, albums and artists and never a playlist. USER_STATE is what a user saved on entities of the library, PLAYBACK is what users listen to. Both tell what a single user did, so declare them only when the plugin works with the data of single users. HookEvent.hookGroup() and EntityType.hookGroup() return the group of an event or entity type, or null when it belongs to none.

Events

Event Meaning
EntitiesCreated(type, ids) Entities of one type were added.
EntitiesUpdated(type, ids) Data of the entities changed, including what they are linked to, such as the artists of a song or the MusicBrainz release of an album.
EntityMembersChanged(type, ids) The content of the containers changed: the songs of a playlist or album, the entries of a collection.
EntitiesDeleted(type, ids) Entities were removed.
EntitiesMerged(type, keptId, removedIds) Entities were merged into one. keptId remains and holds what removedIds had.
LibraryIndexed An indexer finished a run over the library.
PlaylistChanged(playlistId) The songs of a user playlist changed, or the playlist was created without a cover of its own or lost it.
CollectionChanged(collectionId) The entries of a collection changed, or the collection was created without a cover of its own or lost it.
LikesChanged(userId, type, ids) A user liked or unliked songs, starred or unstarred albums, or followed or unfollowed artists.
TimecodesChanged(userId, songIds) A user changed the timecode tags of songs.
NowPlayingChanged(...) A user started, paused, seeked, reported progress on or stopped a song. songId is null on stop, positionMs is the playback position at startedAt, playing tells whether it advances, and generation increases per report so stale events can be dropped.
ListenIngested(listenBrainzUserId, count) count listens arrived for a ListenBrainz account. listenBrainzUserId is the id of that account, not of a Synara user.

type is an EntityType: SONG, ALBUM, ARTIST, USER_PLAYLIST, PLAYLIST (a global playlist) or COLLECTION.

HookEvent.AlbumsLinkedToMusicBrainz belongs to no hook group. The server uses it for its own duplicate check, it is not delivered to plugins and subscribing to it throws an IllegalArgumentException. A plugin learns that an album was linked to a MusicBrainz release, linked to another one or unlinked through EntitiesUpdated with type ALBUM.

Delivery

  • Events about stored data arrive after the transaction that made the change has committed. A change that is rolled back is never announced. PlaylistChanged, CollectionChanged, NowPlayingChanged and ListenIngested are announced right after the operation they describe.
  • Handlers run asynchronously in their own coroutines, outside any transaction. A slow handler delays neither the operation nor other subscribers, only the later events of the same handler. An exception is logged with the plugin id and affects nothing else.
  • An event carries ids, not rows. Read the current state through the library APIs of the PluginContext. For EntitiesDeleted and the removedIds of EntitiesMerged the rows are already gone, so anything a plugin needs about such an entity has to be kept by the plugin while the entity exists.
  • All announcements of one transaction are combined: one event per kind and entity type with all ids. Within a transaction an entity that was created and then updated arrives only in EntitiesCreated, an entity that was created and then deleted does not arrive at all, and the ids a merge removes arrive only in EntitiesMerged, never in EntitiesDeleted, whether they were deleted before or after the merge.
  • A plugin cannot announce events. context.hooks.emit throws an UnsupportedOperationException.
  • The subscriptions of a plugin end when the plugin fails to load.

The hook groups protect against mistakes and document what a plugin reacts to. They are not a sandbox: a plugin runs in the server's process with its class loader, its Koin container and its database connection, so the groups do not contain a hostile jar.

Creating a Plugin

  1. Depend on plugin-api: Add the Synara plugin-api module as a dependency in your project.
  2. Implement ISynaraPlugin: Create a class that implements ISynaraPlugin and any of the marker interfaces above.
  3. Register via ServiceLoader:
    • Create a file named dev.dertyp.plugins.ISynaraPlugin in src/main/resources/META-INF/services/.
    • Add the fully qualified name of your implementation class to this file.

Installing a Plugin

  1. Build the JAR: Package your plugin as a shadow/fat JAR (including all non-provided dependencies).
  2. Deploy: Place the resulting .jar file into the plugins/ directory in the Synara root.
  3. Restart: Restart the Synara server. You should see Loaded plugin: <Your Plugin Name> (<your plugin id>) in the logs.

Server-Driven UI

Plugins can add UI to every Synara client without touching client code. The server sends component trees that clients render natively; see SERVER_DRIVEN_UI.md for the vocabulary and the client side.

Contributions

Implement IUiPlugin and return UiContributions:

class MyPlugin : ISynaraPlugin, IUiPlugin {
    override val id = "myplugin"
    override val name = "My Plugin"
    override val apiVersion = 2

    override fun init(context: PluginContext) {
        context.i18n.registerBundlesFromResources(javaClass.classLoader, "i18n/myplugin", listOf("en", "de"))
    }

    override fun getUiContributions() = listOf(MySettingsEntry(), MySettings())
}

class MySettingsEntry : UiContribution(
    id = "myplugin.settings.entry",     // [a-z0-9._-]+
    kind = UiContributionKind.SLOT,     // SLOT, PAGE or HOME_CARD
    slot = UiSlots.SETTINGS,
    titleKey = "myplugin.settings.title",
    icon = "settings",
    access = UiAccess(requiresAdmin = true),
) {
    override suspend fun render(scope: UiRenderScope): UiComponent = UiComponent.ListItem(
        title = scope.t("myplugin.settings.title"),
        subtitle = scope.t("myplugin.settings.description"),
        icon = icon,
        action = UiAction.OpenPage("myplugin.settings"),
    )
}

class MySettings : UiContribution(
    id = "myplugin.settings",
    kind = UiContributionKind.PAGE,
    titleKey = "myplugin.settings.title",
    icon = "settings",
    access = UiAccess(requiresAdmin = true),
) {
    override suspend fun render(scope: UiRenderScope): UiComponent {
        val current = scope.settings.get("apiKey")
        return UiComponent.Column(
            listOf(
                UiComponent.Form(
                    id = "settings",
                    submit = UiAction.Invoke(id, "save", formId = "settings"),
                    submitLabel = scope.t("myplugin.settings.save"),
                    children = listOf(
                        UiComponent.TextField("apiKey", scope.t("myplugin.settings.apiKey"), secret = true, required = current == null),
                    ),
                ),
            ),
        )
    }

    override fun changes(scope: UiRenderScope): Flow<Unit> = scope.settings.changes().map { }

    override suspend fun invoke(scope: UiRenderScope, actionId: String, values: Map<String, UiValue>): UiInvokeResult {
        val key = values["apiKey"]?.text?.trim().orEmpty()
        if (key.isEmpty()) return UiInvokeResult(UiInvokeStatus.VALIDATION_ERROR, fieldErrors = mapOf("apiKey" to scope.t("myplugin.settings.error.apiKey")))
        scope.settings.set("apiKey", key)
        return UiInvokeResult(UiInvokeStatus.OK, scope.t("myplugin.settings.saved"), refresh = true)
    }
}
  • A slot item is an entry, not a screen: in the list slots (settings, library) render a ListItem or Tile that opens a PAGE contribution, and put the forms and cards on that page. Only importer and the *.detail slots render inline sections — a Card placed into the host screen.
  • render is called with a UiRenderScope carrying the user (scope.user), the host context (entity or page params), a translator (scope.t), the plugin's settings, the client's schema version and the client's time zone (scope.timeZone, an IANA id, or null when the client sends none).
  • toolbar (pages only) returns components for the native app bar — buttons with icons, portals — so the page body holds content only.
  • live(scope, key) serves a UiComponent.Live(key, child) node in your tree: return a Flow<UiLiveUpdate> (Replace(child) or AppendLines(lines) for a Log child) so frequent data such as process output updates that subtree only, without re-rendering the page. Return null for unknown keys. Example: UiComponent.Live("log", UiComponent.Log(currentLines)) in render, and live returning process.output.map { UiLiveUpdate.AppendLines(listOf(it)) }.
  • changes returns a Flow<Unit> when the contribution is live; the server re-renders subscribers on each emission. Return null (the default) for static content.
  • invoke handles UiAction.Invoke from the client. Throw IllegalArgumentException or return VALIDATION_ERROR with fieldErrors for bad input; set refresh = true to re-render, and next to make the client perform a follow-up action (e.g. UiAction.OpenUrl for OAuth).
  • search(scope, key, query) serves the UiComponent.SearchField nodes in your tree: return the UiOptions matching query for the field with that key. The server passes at most 50 to the client and calls it with the same scope as render. Older clients get a submit-to-search version of the field without any work on your side.
  • hooks + onHook let a contribution offer to handle app events such as a shared URL. Return a UiHookOffer describing the action (optionally with a confirmKey so the client asks before performing it); the client lets the user choose when several plugins offer.
  • access restricts who sees and may invoke the contribution (requiresAdmin, required UserCapabilitys). Authorization is enforced by the server.
  • Use context.ui.invalidate(id) to push a re-render from outside the flow.

The slots available today and the built-in contributions in each are listed in SERVER_UI_CONTRIBUTIONS.md.

Secret fields (TextField(secret = true)) must never echo the stored value; treat an empty submitted value as "unchanged".

Settings

context.settings (and scope.settings) is a per-plugin key/value store persisted in the database. Values are strings; parse them yourself. changes() emits the full map whenever something is written.

Intake resolvers

Users hand the app links, catalog codes, ids and text (IntakeItem, see SERVER_DRIVEN_UI.md). Register an IntakeResolver through context.intake to offer handling them — the importer is one resolver per backend, plugins can add their own:

context.intake.register(object : IntakeResolver {
    override val id = "myplugin.mbid"
    override val titleKey = "myplugin.intake.title"          // "Look up on MusicBrainz"
    override val jobKind = "myplugin"
    override val access = UiAccess(capabilities = setOf(UserCapability.IMPORT))

    override suspend fun offer(items: List<IntakeItem>, user: UserInfo): IntakeOffer? {
        val mine = items.filterIsInstance<IntakeItem.Id>().filter { it.provider == "mbid" }
        if (mine.isEmpty()) return null
        return IntakeOffer(accepted = mine, confirmKey = "myplugin.intake.confirm", submit = {
            context.jobs.enqueue(jobKind, "MusicBrainz lookup", user, mine.joinToString { it.id }) {
                mine.forEachIndexed { index, item ->
                    log("Resolving ${item.id}")
                    progress((index + 1).toDouble() / mine.size)
                    if (!isActive()) return@enqueue
                }
            }
            IntakeReceipt(accepted = mine.size, messageKey = "myplugin.intake.queued")
        })
    }
})
  • offer returns the subset it accepts. Either submit (does the work, usually by enqueuing a job, and returns an IntakeReceipt) or action (navigational, e.g. open a page) — never both.
  • When several resolvers accept the same item the server does not pick: the client shows a chooser and calls back with the chosen resolver. priority orders the chooser; titleArgs fill {placeholders} in the title key.
  • access is enforced before offer is called.

Jobs

context.jobs is a per-kind FIFO queue with one running job per kind (kinds run in parallel): enqueue(kind, title, user, summary) { … } returns the job id; inside the body use log(line), progress(value, message) and isActive() (false once cancelled). jobs(kind, user) streams JobInfos (pending/running/finished with progress), log(jobId) streams a job's lines, cancel(jobId) cancels your own source's jobs. Imports run in kind "import", favourites syncs in "favourites".

Credentials

context.credentials stores and reads secrets such as API keys and logins. It was added with API version 3 and is available to every loaded plugin regardless of its declared apiVersion. Names are scoped to your plugin, so two plugins can both use the name api. get(name) returns the ResolvedCredential or null, store(name, credential) saves one and remove(name) deletes it.

Without a credential server configured, credentials are kept encrypted in the local database. With a credential server, grants are looked up as plugin:<pluginId>:<name>. managedRemotely tells whether the credential server is in use. Names managed remotely cannot be changed from the plugin, so store and remove are rejected for them.

The Credentials pages show your credentials under a label from your own translation bundle (registered through context.i18n). Ship credentials.name.<name> and optionally credentials.about.<name> as a short description, where <name> is the name you pass to context.credentials. Without them, the raw name plugin:<pluginId>:<name> is shown.

Podcast indexes

An IPodcastIndex is a searchable external podcast directory.

  • id must be unique and stable — clients pass it to searchIndex.
  • name is shown to users.
  • isConfigured() says whether the index can be searched right now (for example credentials are present); unconfigured indexes are skipped.
  • search(query, limit) returns up to limit PodcastIndexEntry values with at least feedUrl and title; it may throw, in which case the server logs the failure and treats the index as empty for that call, and applies a 10 second timeout per index.

The server normalizes and deduplicates results by feed URL across indexes and leaves out feeds it already follows.

The built-in podcastindex plugin (Podcast Index, credentials via PODCAST_INDEX_API_KEY/PODCAST_INDEX_API_SECRET or the admin settings page) is the reference implementation.

Translations

All text in a tree must be translated on the server. Register bundles with context.i18n:

  • registerBundlesFromResources(classLoader, "i18n/myplugin", listOf("en", "de")) loads i18n/myplugin/en.json etc. from your jar — flat JSON objects of key → text.
  • registerBundle(locale, messages) registers or replaces a bundle at any time, e.g. after fetching translations remotely; live subscribers are re-rendered.
  • Lookup order for scope.t(key): your plugin's exact locale → language → en, then the core bundles the same way, then the key itself. Placeholders use {name}: scope.t("greeting", "user" to name).

Versioning

Plugins specify an apiVersion. Synara will only load plugins with an apiVersion less than or equal to the server's current supported version. This ensures backward compatibility as the plugin API evolves.

Current Supported API Version: 4 (adds hookGroups to ISynaraPlugin, scopes context.hooks to the plugin and adds the entity events, LikesChanged, TimecodesChanged and LibraryIndexed). Version 3 added credentials to PluginContext. Version 2 added ui, settings and i18n to PluginContext and the IUiPlugin interface. Plugins with version 3, 2 or 1 still load.

A plugin declares its hookGroups from version 4 on, and a version 4 plugin that subscribes to an event of a group it does not declare does not load.

A plugin with version 3, 2 or 1 could not declare groups, so the server grants it PLAYLISTS and PLAYBACK in addition to anything it declares. These are the groups of the four events that existed before version 4: PlaylistChanged, CollectionChanged, NowPlayingChanged and ListenIngested. Such a plugin keeps receiving them without a change and cannot subscribe to an event of another group.

hooks.emit throws for every version, so a plugin that called it has to stop doing so.