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, theindexerandimportServiceof the server - the library APIs (
songLibrary,albumLibrary,artistLibrary,playlistLibrary,imageLibrary) metadataServiceandlrcLibService- the
scheduleServicefor scheduled tasks - the
hooksof the plugin (see Hooks) apiKeyScopes,ui,settingsandi18n(see Server-Driven UI)intakeandjobs(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,NowPlayingChangedandListenIngestedare 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. ForEntitiesDeletedand theremovedIdsofEntitiesMergedthe 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 inEntitiesMerged, never inEntitiesDeleted, whether they were deleted before or after the merge. - A plugin cannot announce events.
context.hooks.emitthrows anUnsupportedOperationException. - 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
- Depend on
plugin-api: Add the Synaraplugin-apimodule as a dependency in your project. - Implement
ISynaraPlugin: Create a class that implementsISynaraPluginand any of the marker interfaces above. - Register via
ServiceLoader:- Create a file named
dev.dertyp.plugins.ISynaraPlugininsrc/main/resources/META-INF/services/. - Add the fully qualified name of your implementation class to this file.
- Create a file named
Installing a Plugin
- Build the JAR: Package your plugin as a shadow/fat JAR (including all non-provided dependencies).
- Deploy: Place the resulting
.jarfile into theplugins/directory in the Synara root. - 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 aListItemorTilethat opens aPAGEcontribution, and put the forms and cards on that page. Onlyimporterand the*.detailslots render inline sections — aCardplaced into the host screen. renderis called with aUiRenderScopecarrying the user (scope.user), the hostcontext(entity or page params), a translator (scope.t), the plugin'ssettings, 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 aUiComponent.Live(key, child)node in your tree: return aFlow<UiLiveUpdate>(Replace(child)orAppendLines(lines)for aLogchild) so frequent data such as process output updates that subtree only, without re-rendering the page. Returnnullfor unknown keys. Example:UiComponent.Live("log", UiComponent.Log(currentLines))inrender, andlivereturningprocess.output.map { UiLiveUpdate.AppendLines(listOf(it)) }.changesreturns aFlow<Unit>when the contribution is live; the server re-renders subscribers on each emission. Returnnull(the default) for static content.invokehandlesUiAction.Invokefrom the client. ThrowIllegalArgumentExceptionor returnVALIDATION_ERRORwithfieldErrorsfor bad input; setrefresh = trueto re-render, andnextto make the client perform a follow-up action (e.g.UiAction.OpenUrlfor OAuth).search(scope, key, query)serves theUiComponent.SearchFieldnodes in your tree: return theUiOptions matchingqueryfor the field with thatkey. The server passes at most 50 to the client and calls it with the same scope asrender. Older clients get a submit-to-search version of the field without any work on your side.hooks+onHooklet a contribution offer to handle app events such as a shared URL. Return aUiHookOfferdescribing the action (optionally with aconfirmKeyso the client asks before performing it); the client lets the user choose when several plugins offer.accessrestricts who sees and may invoke the contribution (requiresAdmin, requiredUserCapabilitys). 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")
})
}
})
offerreturns the subset it accepts. Eithersubmit(does the work, usually by enqueuing a job, and returns anIntakeReceipt) oraction(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.
priorityorders the chooser;titleArgsfill{placeholders}in the title key. accessis enforced beforeofferis 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.
idmust be unique and stable — clients pass it tosearchIndex.nameis 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 tolimitPodcastIndexEntryvalues with at leastfeedUrlandtitle; 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"))loadsi18n/myplugin/en.jsonetc. 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.