Development
Working on the Synara server itself: cloning, running, testing, and the workflows around generated files and the common-rpc submodule. For the module map and request flow, see ARCHITECTURE.md. For building a client, see docs/README.md.
Clone
common-rpc is a git submodule (.gitmodules → synara-common-rpc), shared with the client apps. Clone with:
git clone --recurse-submodules <repo-url>
If you already cloned without it: git submodule update --init --recursive. Forgetting --recurse-submodules is the most common bootstrap failure — the build fails with common-rpc sources missing.
Prerequisites
- JDK 25 (the convention plugin
synara.kotlin-jvminbuild-logic/src/main/kotlin/synara.kotlin-jvm.gradle.ktssetskotlin { jvmToolchain(25) }for every JVM module; CI builds with Temurin 25). - Docker and Docker Compose for the dev stack and for the PostgreSQL tests (Testcontainers).
Configuration
Copy example.env to .env and fill in the variables you need. CLIENT_ID/CLIENT_SECRET (client.id/client.secret) seed the first admin account on startup (DatabaseManager.init() in server/src/main/kotlin/dev/dertyp/services/DatabaseManager.kt, inserted with insertIgnore so only the first run against an empty database creates it); without them a fresh server has no user to log in with. See ENVIRONMENT_VARIABLES.md for the rest of the variables, what each one does, and its default.
Running
| Method | Command | Notes |
|---|---|---|
| Gradle | ./gradlew :server:run |
Runs the server alone against whatever .env/config you have set up. |
dev.sh |
./dev.sh |
Builds :proxy:installDist and :server:installDist, loads .env, then runs both the proxy and the server together; Ctrl+C stops both. |
| Docker Compose (dev stack) | docker compose up |
docker-compose.yml: the api service is built from Dockerfile.nobuild, which copies a prebuilt *.jar from the repository root into the image and does not compile anything, so run ./gradlew :server:shadowJar first and copy server/build/libs/server-all.jar to the repository root. The service uses network_mode: host, reads .server.env next to the compose file and mounts /home/typ/Music as /data, adjust the volume paths for your machine. The stack also starts PostgreSQL, the Redis cluster image, and the transcriber/audio-embed/recsys sidecars, which are built from source. The root Dockerfile is the image that compiles the server itself. |
| Docker Compose (built images) | see docker-compose.server.yml / docker-compose-prod.yml |
Run the ghcr.io/dertyp7214/synara* images instead of building locally; -server.yml puts services on a proxy network for use behind :proxy, -prod.yml uses network_mode: host. |
Git hooks
./gradlew installGitHooks points git at .githooks (git config core.hooksPath .githooks). The pre-commit hook runs generateDocs whenever a staged path starts with common-rpc/, server/src/main/, plugin-api/src/main/ or proxy/src/main/resources/application.yaml, is Dockerfile.nobuild, the root build.gradle.kts or server/build.gradle.kts, or is the common-rpc submodule pointer itself, and stages every regenerated output (docs/RPC_SERVICES.md, docs/MODELS.md, docs/REST_API.md, docs/PERMISSIONS.md, docs/API_CONSTANTS.md, docs/SERVER_UI_CONTRIBUTIONS.md, docs/SERVER_UI_CONTRIBUTIONS.json, docs/ENVIRONMENT_VARIABLES.md, example.env). A failed generateDocs aborts the commit. Before that, the hook formats the staged Kotlin files with ktlint --format and the code style in .editorconfig (ktlint's intellij_idea style with the wrapping, signature, naming and import rules switched off, continuation indent 4) and restages them. IntelliJ reads the same .editorconfig. It skips files with unstaged changes and needs ktlint on the PATH. It never blocks a commit when ktlint is missing or reports findings it cannot fix. Facts a client needs — versions, features, auth constants, permissions — are generated: add them to the generator that produces the relevant doc, never to a hand-written guide.
Tests and goldens
./gradlew test runs the full suite with the Kover agent attached to every test JVM (CI additionally runs koverXmlReport koverHtmlReport). -PwithoutCoverage=true runs the tests without the agent, which takes about a tenth less time, and the Kover report tasks then have no coverage to report.
- Docker: the PostgreSQL and Redis tests of the server use Testcontainers, so Docker must be reachable — CI sets
DOCKER_HOST=unix:///var/run/docker.sock; locally, make sure your Docker daemon is running andDOCKER_HOST(if set) points at it. A container that does not start fails every test that needs it, with a message naming the container. - Without Docker:
-PwithoutContainers=trueis the deliberate way to run without Docker: the PostgreSQL and Redis tests are then skipped with a reason, and the SQLite cases still run, so a green result says nothing about PostgreSQL or Redis. CI never passes it. - Output: each test JVM ends with one line in the Gradle output,
TEST CONTAINERS: PostgreSQL 15.x (container postgres:15-alpine, port …), Redis 7.x (container …, port …), test JVM <pid>, which names the servers that JVM ran its tests on and the host ports it reached them at, orFAILEDornot used, and with-PwithoutContainers=truesays that the PostgreSQL and Redis tests were skipped. A run prints as many of these lines as it has test JVMs.
:server:test runs its classes in several test JVMs at once (maxParallelForks in server/build.gradle.kts). The number is a quarter of the available processors, at most 4, and at most what the memory holds at 3 GB per test JVM once 7 GB are set aside for Gradle, the Kotlin daemon and the containers, so a full run leaves most of a machine free for other work. That gives 4 on a machine with 16 processors and 19 GB or more, and 1 on a GitHub runner with 4 processors, where CI passes -PtestForks=2. -PtestForks=N sets the number: a higher one makes the run faster as long as cores are free, and -PtestForks=1 runs every class in one JVM. Gradle deals whole classes to the JVMs in turn, so the classes that share a JVM change with the number of JVMs and with every class that is added, and inside one JVM the classes still run one after the other. A test therefore:
- assumes nothing about the classes that ran before it in its JVM, and leaves no global state behind (static and object mocks, the Koin context, system properties),
- binds no fixed port: a server it starts listens on port 0 or behind a mapped container port,
- writes only to files and directories it created under a unique name (
createTempFile,createTempDirectory,@TempDir), never to a fixed name in a shared directory, - takes its database from
TestDatabaseand its Redis fromTestRedis, - waits for the event it expects instead of sleeping a fixed time, and takes the waits it needs from the durations it configured, because several JVMs make every step slower.
The PostgreSQL container (TestDatabase) is built for speed, not for keeping data: fsync, synchronous_commit and full_page_writes are off, the data directory is a 2 GB tmpfs, and logins need no password (POSTGRES_HOST_AUTH_METHOD=trust).
- Reuse: locally it is reused between runs when
~/.testcontainers.propertieshastestcontainers.reuse.enable=true, and all test JVMs of a run share it: the first one creates it while it holds a lock onsynara-test-postgres.lockin the system temp directory, the others find it afterwards. Without reuse, as in CI, every test JVM starts its own container and Testcontainers removes it when the JVM ends. A change to these settings makes Testcontainers start a new container and leaves the previous one running, remove that one withdocker rm -f -v <name>. - Databases: every PostgreSQL case gets its own database, reached through a small connection pool that
TestDatabase.cleanUp()closes before it drops the database. Databases are created (STRATEGY FILE_COPY) and dropped over one administrative connection per test JVM, which is closed when the JVM ends. - Tables: a test passes its tables to
TestDatabase.connect(dialect, name, tables...). On PostgreSQL the schema of each distinct table list is built once per test JVM in a template database (template_<jvm>_<id>, found again by the hash of the DDL Exposed generates for the list) and every case gets a copy of it withCREATE DATABASE ... TEMPLATE, on SQLite the tables are created in the fresh file. Tables or raw DDL a single test needs on top are created in that test. - Migrated databases:
TestDatabase.connectMigrated(dialect, name)andTestDatabase.getMigratedPostgresDbUrl(name, upTo)give a database that Flyway migrated from the base, on PostgreSQL again as a copy of a template that is migrated once. A test about applying the base to an empty database starts fromconnectwithout tables orgetPostgresDbUrl. - Cleanup: templates accept no connections and are dropped when the test JVM ends.
The Redis container (TestRedis, redis/redis-stack-server:7.4.0-v8) is a cluster of one node per test JVM, on a mapped host port and never reused. The node announces that host port (cluster-announce-ip, cluster-announce-port), so the cluster client is sent to an address it can reach, and TestRedis.host and TestRedis.port are what a test passes to RedisCacheProvider.Config. The tests start once the node reports cluster_state:ok.
Search indexes need extra care. FT.CREATE scans the existing keys in the background and indexes a hash written before the scan passes it a second time. The first posting stays in the index until its garbage collection, the document frequency of the term then exceeds the number of documents, the default scorer (TFIDF) computes an IDF of 0 and every match scores 0, so the field weights no longer decide the order. A test that creates search indexes therefore calls TestRedis.awaitSearchIndexes(jedis, indexPrefix) after RedisSearchService.initIndex() and writes its documents only after that: it returns once no index of the prefix reports indexing.
In the reports a database case is named test name(DbDialect) [1] POSTGRES or … [2] SQLITE (junit.jupiter.params.displayname.default in server/src/test/resources/junit-platform.properties).
JUnit does not run a test method that returns a value, and reports nothing about it. A test written as an expression body (= runBlocking { … }) whose last expression has a value, such as assertThrows or assertNotNull, is such a method, so it gets : Unit. TestMethodReturnTypeTest fails for every test method of the server test sources that returns a value.
REST route goldens live at server/src/test/resources/rest/routes.golden and routes.openapi.golden, checked by RestRouteGoldenTest (server/src/test/kotlin/dev/dertyp/routing/rest/RestGoldenSupport.kt). After a routing change, refresh them with:
./gradlew :server:test -PupdateRestGolden=true --tests 'dev.dertyp.routing.rest.RestRouteGoldenTest'
then review the diff — this is what turns REST routing changes into an intentional, reviewed golden update instead of a silent behavior change.
The generated Kotlin REST clients (see Generated files) are tested against the same golden and against the real routes. RestClientRouteGoldenTest checks every generated client call (verb, path and parameters) against routes.golden and every URL builder against the request of its call, so it fails when a client and the server disagree about a route and is refreshed together with the golden above. RestClientRoundTripTest runs the clients against the generated server routes. It also fetches URLs that the builders returned. Both are in server/src/test/kotlin/dev/dertyp/routing/rest/ and run with ./gradlew :server:test --tests 'dev.dertyp.routing.rest.RestClient*'.
The reverse proxy tunnel is covered on three levels:
common-proxy:ProtocolTestandHttpTunnelCreditTestcheck the frame encoding and the credit windows.proxy:ProxyHttpTunnelTest, next toProxyApplicationTest, runs the proxy routes against a fake server connection.server:ReverseProxyRouteAllowlistTestchecks which method and path pairs the tunnel lets through,ReverseProxyHttpGatewayTestthe replay against the loopback port, andReverseProxyRestEndToEndTestruns the real proxy against the real generated routes.
RestClientRouteGoldenTest also checks the generated clients with a base URL that has a path prefix, as used behind the proxy. They run with ./gradlew :common-proxy:test :proxy:test and ./gradlew :server:test --tests 'dev.dertyp.services.ReverseProxy*'.
python3 scripts/test-times.py reads the JUnit XML reports of the last run and prints the test times per module, database type and class; --json <file> saves them and --compare <file> shows the change against such a file, for the classes both have a report for. Gradle keeps only the reports of the last run of a test task, so a run with --tests leaves the reports of those classes alone.
python3 scripts/commit-stats.py regenerates STATS.html, an interactive page of commit statistics to open in a browser, from the git history of this repository and of the sibling checkouts synara-android and SynaraComposeMultiplatform, when they exist next to it; -o <file> writes it elsewhere. It only reads from git, and unchanged history gives a byte-identical page. The pages workflow also checks out SynaraDesktop and synara-android, so the published page covers this repository, common-rpc and both clients.
The build cache and the configuration cache are on (org.gradle.caching and org.gradle.configuration-cache in gradle.properties). A compile or test task whose inputs are unchanged does not run: the output says UP-TO-DATE, or FROM-CACHE when the result comes from ~/.gradle/caches/build-cache-1, for example after clean or a switch back to a branch that was built before. This holds for tests too, so ./gradlew test without a change runs no test, and ./gradlew test --rerun runs them all. The KSP tasks declare the docs they write as outputs, so a generated doc that was edited or reverted by hand is written again by the next generateDocs.
The server reports its build time and commit through BuildInfo, which reads build-info.properties from the classpath. Only :server:shadowJar puts that file into the jar, written by :server:generateBuildInfo on every jar build with the time of the build and git rev-parse HEAD (unknown without a git checkout). A server that does not run from the fat jar (:server:run, installDist, dev.sh, tests) reports dev for both, so compiling and testing do not depend on the time or the commit. The version stays the compile-time constant BuildConfig.VERSION.
Never verify with ./gradlew assemble; it also builds every common-rpc multiplatform target (iOS, tvOS, macOS, Windows, Linux, see common-rpc/build.gradle.kts), which needs toolchains most machines do not have. Verify with the JVM tasks CI runs instead: ./gradlew test, ./gradlew :server:shadowJar.
Generated files
Never hand-edit these; regenerate them instead. CI fails the build if any of them is stale (the build-artifact job of .github/workflows/build.yml runs the generators together with the jars and then git diff --exit-code on their outputs). ./gradlew generateDocs runs every generator below in one go.
| File | Generated by | Runs |
|---|---|---|
| RPC_SERVICES.md, MODELS.md, PERMISSIONS.md | ./gradlew :common-rpc:kspCommonMainKotlinMetadata |
As part of generateDocs, by the pre-commit hook, and by CI's docs-drift check. |
REST_API.md, and the register*Rest functions in dev.dertyp.routing.rest |
./gradlew :server:kspKotlin |
Automatically as part of any :server compile; also as part of generateDocs, by the pre-commit hook, and by CI's docs-drift check. |
| API_CONSTANTS.md | ./gradlew :server:generateApiConstantsDocs |
As part of generateDocs, by the pre-commit hook, and by CI's docs-drift check. |
| SERVER_UI_CONTRIBUTIONS.md, SERVER_UI_CONTRIBUTIONS.json | ./gradlew :server:generateUiContributionDocs |
As part of generateDocs, by the pre-commit hook, and by CI's docs-drift check. The generator runs on the test classpath and registers the core contributions and those of the built-in plugins with mocked services. |
ENVIRONMENT_VARIABLES.md, example.env |
./gradlew generateEnvDocs (root build.gradle.kts) |
As part of generateDocs, by the pre-commit hook, and by CI's docs-drift check. |
| RELATION_MAP.html | python3 scripts/relation-map/generate.py (needs Graphviz) |
By hand for the file in docs/, not part of generateDocs or the build workflow. .github/workflows/pages.yml regenerates it on every push to master and publishes it to the gh-pages branch. The system, credentials, API surface, pipelines, workers, events, startup and build views are curated data in scripts/relation-map/curated/, update them by hand when a flow changes. The workers view groups the workers read from the @WorkerTask annotations, the packages and classes come from the Kotlin sources and the database from the base schema. |
| STATS.html | python3 scripts/commit-stats.py |
By hand for the file in docs/, not part of generateDocs or the build workflow. .github/workflows/pages.yml regenerates it on every push to master and publishes it to the gh-pages branch. The numbers change with every commit, so regenerate the file in docs/ when they should be current. |
| UI_CATALOGUE.html | python3 scripts/ui-catalogue.py |
By hand for the file in docs/, not part of generateDocs or the build workflow. .github/workflows/pages.yml regenerates it from the committed SERVER_UI_CONTRIBUTIONS.json on every push to master and publishes it to the gh-pages branch. |
| MIGRATION_TIMELINE.html | python3 scripts/migration-timeline.py |
By hand for the file in docs/, not part of generateDocs or the build workflow. .github/workflows/pages.yml regenerates it on every push to master and publishes it to the gh-pages branch. It reads the whole git history, so it needs a full clone. |
The Kotlin REST clients are generated too, but into a build directory and not into the repository. The Kotlin Multiplatform module :common-rpc:rest-client (common-rpc/rest-client/build.gradle.kts) holds the hand-written runtime in common-rpc/rest-client/src/commonMain/kotlin/dev/dertyp/rpc/rest/client/ and runs the rest-compiler KSP processor in client mode, which writes one Rest<Name>Service class per @Rpc interface to common-rpc/rest-client/build/generated/ksp/metadata/commonMain/kotlin/dev/dertyp/rpc/rest/client/. Each routed method <method> also gets a public <method>Url builder and a private <method>Request function that both use. An interface function named <method>Url or <method>Request next to a routed <method> is a KSP error. Which interfaces get a client is not configured there: :common-rpc:compiler writes the list as rpc-interfaces.txt into the build output of :common-rpc while it generates the service registry, and the client module reads that file. The processor takes these options:
| Option | Meaning |
|---|---|
rest.mode |
server (default) generates the server routes, client generates the client classes. |
rest.client.package |
The package of the generated client classes, required in client mode. The module sets dev.dertyp.rpc.rest.client. |
rest.interfacesFile |
A file with one fully qualified interface name per line that is processed in addition to the interfaces found by the other options. The module passes the rpc-interfaces.txt of :common-rpc. |
The module compiles for the JVM and the same native targets as common-rpc, so verify it with ./gradlew :common-rpc:rest-client:compileKotlinJvm. The native C bindings and static libraries of common-rpc no longer exist. A checkout that was built before that change must delete common-rpc/build once, because stale generated sources from the removed JVM KSP run break the compile.
index.html is written by hand and links both pages. The pages workflow publishes it next to them as the start page and fills in the commit and date of the build.
If a KSP task reports up-to-date but you know its output should change, touch one of its input source files — Gradle otherwise skips it.
common-rpc submodule workflow
- Make the interface/model change inside
common-rpc/and commit it there first. - Regenerate the docs and goldens affected by the change (see Generated files, and Tests and goldens above).
- Back in the server repo, stage the updated submodule pointer (
git add common-rpc) and commit it together with the server-side change that depends on it and the regenerated docs — this combined commit is the "pointer bump".
Adding an RPC method end to end
- Declare the method on the service interface in
common-rpc/src/commonMain/kotlin, documented with@RpcDoc/@RpcParamDoc(and, for REST,@RestGet/@RestPost/@RestPut/@RestDelete,@RestPath,@RestPublic,@RestFileResponseas needed — all incommon-rpc/src/commonMain/kotlin/dev/dertyp/rpc/annotations/Doc.kt). Gate access with@RequiresCapability/@RequiresAdmin(common-rpc/.../data/UserCapability.kt) if it shouldn't be open to every authenticated user. - Implement it in the server. Find the existing
Rpc*Servicepattern for the service you're extending withgrep -rl "class Rpc" server/src/main/kotlin/dev/dertyp/services. - Register the service factory in both
routing/RpcRegistry.kt(registerAuthenticatedServices/registerPublicServices) androuting/RestRegistry.kt(registerAuthenticatedRestServices/registerPublicRestServices) if it's a new service; an added method on an already-registered service needs no registry change. - Refresh the REST goldens (
-PupdateRestGolden=true, see above) if the route set changed. - Regenerate
RPC_SERVICES.md/MODELS.mdandREST_API.md(see Generated files). - Commit inside the
common-rpcsubmodule, then bump its pointer in the server repo (see the submodule workflow above).
Conventions
- Foreign keys are always Exposed
reference()columns, never a hand-rolleduuid/varcharid column mirroring another table's id. - Schema changes are a pair of generated SQL files, see Schema changes; data backfills and one-off fixes are custom migrations (
migrations/custom/,@Migration("3.33")onward) — see ARCHITECTURE.md. - Recurring/periodic work is a
@WorkerTaskworker (services/schedule/), never an ad hocwhile/delayloop. - Every write to a table behind a tracked entity (songs, albums, artists, playlists, collections and the per-user likes, follows and timecode tags) is announced through
EntityEventPublisherin the same transaction as the write, or classified as bookkeeping inserver/src/test/resources/entity-change/write-sites.txt. A write that reaches such a table through a helper rather than directly is listed inindirect-write-sites.txtin the same directory.EntityChangeRecordersubscribes to these announcements and records the change in that transaction.EntityChangeCoverageTestreads both files, enforces this and fails for a write that is neither. - A custom migration that changes data clients can read announces the changed entities through
EntityEventPublisherin the same transaction, like a service does.EntityChangeRecorder.restartTracking()is only for the cases that cannot name what they changed, such as a database restore. - A reaction to an entity event is a subscriber, not a call a service places after its write. The class implements
HookSubscriber, is bound in Koin withbind<HookSubscriber>()and subscribes insubscribe(hooks).configureHooks()(Hooks.kt) calls every bound subscriber before the database is ready, sosubscribeandinitonly register and never read data.HookSubscriberBindingTestfails for a subscriber class that no production module binds asHookSubscriber. Services announce throughEntityEventPublisherand do not know who listens. - A subscriber runs in one of two phases.
HookService.inTransactionregisters anEntityWriteSubscriberthat is called synchronously inside the transaction of the write, and is only for data that must commit or roll back with that write, asEntityChangeRecorderdoes. Everything else subscribes withHookService.onand runs asynchronously after the commit, with one event per kind and entity type for the whole transaction. Plugins get the after-commit phase only, throughHookService.forPlugin, limited to the hook groups they declare (see PLUGINS.md). - A new
HookEventgets its hook group inHookEvent.hookGroup(), or null when only the server reacts to it, as forAlbumsLinkedToMusicBrainz, and a sample inPluginHooks, and a line in PLUGINS.md.PluginHooksTestfails for an event without a sample. VersionGroupTriggerrebuilds the album version groups after album events: afterVERSION_GROUP_REBUILD_QUIET_SECONDS(1) without a further event, and at the latestVERSION_GROUP_REBUILD_MAX_WAIT_SECONDS(5) after the first one that is waiting. Both come fromVersionGroupConfig, where a quiet period below one second counts as one second and a maximum wait below the quiet period counts as the quiet period. All commits of one operation therefore lead to one rebuild on complete data.- Code that changes what the grouping reads without an album event, such as
AlbumService.setMusicBrainzIdfilling in the cached release group, callsVersionGroupTrigger.requestRebuild(), which joins the same wait. - A caller that needs the groups rebuilt before it continues awaits
AlbumService.rebuildVersionGroups()itself. - A rebuild that is still waiting when the server stops is not run.
- Tests give the trigger shorter waits through
RecordedEntityEvents.
- Code that changes what the grouping reads without an album event, such as
- REST routes are generated, not hand-written; control them through the
common-rpcdoc annotations, not by editingrouting/rest/.
Schema changes
The schema starts from a frozen base per database type, and a schema change is a pair of SQL files, one for PostgreSQL and one for SQLite, generated from the table objects.
- Change the table object in
server/src/main/kotlin/dev/dertyp/db/. - Run
./gradlew :server:generateMigration -Pname=AddSomething. It needs Docker, or an existing PostgreSQL given with-PpostgresUrl,-PpostgresUserand-PpostgresPassword. - Review both new files in
server/src/main/resources/db/migrations/postgres/and.../sqlite/. Append by hand what Exposed cannot generate, such as search triggers, functions, GIN or expression indexes. Commit both files, even if one of them is empty. - A released migration and the base (
B1_109__Base.sql) are never edited. A correction is a new migration.
The generator writes what Exposed proposes, and three kinds of change need a correction by hand before the files are committed:
- A renamed column is generated as
ADDof the new column andDROP COLUMNof the old one, which deletes its data. Replace both statements byALTER TABLE ... RENAME COLUMN ... TO ...in both files. The generator prints a warning for a table that gains and loses columns in the same run. - A new
NOT NULLcolumn without a database default (default(...), notclientDefault) fails on PostgreSQL once the table has rows and always on SQLite. The generator prints a warning for it. - On SQLite, a changed type, default or nullability of an existing column and a new foreign key on an existing table produce no statement, so the SQLite file stays without them and
SchemaDriftTestcannot see the difference on SQLite. The table rebuild (new table, copy, drop, rename) is written by hand.
SchemaDriftTest fails for a table change without a migration, and MigrationFilesTest for a file that exists for only one database type or for a Flyway Kotlin class. Data changes stay custom migrations in the package dev.dertyp.migrations.custom, which is created again with the next one.
At startup MigrationBaseCheck refuses a database that is not at the base (see ARCHITECTURE.md), and the message names the 0.0.1-dev image to start first, see Updating from 0.0.1. Backups carry the schema version they were made at, and a restore applies the same rule before it changes anything, so a new migration needs no change to the backup code.
Listen sharing
Two accounts can be linked so that each sees the other's listens. A link is a row in listen_share (ListenShareTable) with the two user ids stored as an ordered pair (userA < userB) and a useForRecommendations flag. Links are pairwise and not transitive. ListenShareService holds the rules: an admin links any two users, a user links itself to another user only while both are linked to the same ListenBrainz account, and an admin or either member can unlink or toggle the flag at any time. A later change of the ListenBrainz link keeps existing links.
Writes stay per user. Every play is recorded for the account that played it, and ingest, ListenBrainzService, ListenBackupService and the co-occurrence model of RecommendationService never look at links.
Reads choose their owners through listenOwnerPredicate(userId, scope) in db/ListenTable.kt. It matches the listens of the owner users and of their linked ListenBrainz accounts. The scope has no default, so every query names one:
OWNis the user alone. Linked groups,relinkSongandlinkUnmatcheduse it, because they write overrides and redirects for that user only.SHAREDadds every partner. Recent listens, artists and albums, listening stats and top songs, the MCP listen history and the Subsonic frequent and recent album lists use it.RECOMMENDATIONSadds the partners of links withuseForRecommendationson.ListenService.recentSeedWeightsuses it for the listen part, while favourites and super likes stay per user.
ListenService.listensChanged notifies the partners of every affected user as well, so a partner's screens update live. Every link change notifies both members and invalidates the core.listenshare.settings and core.listenshare.admin UI contributions.
Recent listens, stats and the MCP summary deduplicate plays by time across the merged stream (ListenTable.DEDUP_WINDOW_MS, 2 s). If both linked users play the same song within that window, the two plays count once.
Lyrics providers
Every lyrics source is a candidate of the song. LyricsCandidateService (services/lyrics/) stores one row per song and provider in lyrics_candidate (LyricsCandidateTable): a found answer keeps its sync level, match, duration gap, CBOR encoded SyncedLyrics and plain text, a miss keeps only the time it was asked, and a failed lookup (a provider error or exception) is stored with status ERROR and the time it failed. ERROR rows are bookkeeping only: they are never a candidate, never ranked, never the default and never count for lyrics info, tag filters, lyrics search or LyricsSyncWorker.
Three candidates are virtual and never stored:
songisSongTable.lyrics(parsed as LRC, so an LRC text is line synced).whisperxis the transcription insynced_lyrics.manualis a stored row written bysetSyncedLyricsandimportSyncedLyrics.
Virtual candidate ids are the song id XORed with a mask derived from the provider id, so they stay stable without a row.
Providers live in services/lyrics/providers/ and implement LyricsProvider. LyricsProviderRegistry enables them in the order of LYRICS_PROVIDERS (lyrics.providers in application.yaml). Unknown ids are logged and skipped, and an empty list leaves only the sidecar reader. LyricsHttp applies the shared HTTP rules (401, 403, 404 and 410 are a miss, cross-host redirects are refused, bodies are capped at 512 KiB), and every request goes through HttpClientQueueService, which spaces requests per host. The provider hosts wait 2 s between requests, while lrclib.net keeps the default 250 ms because LrcLibWorker also uses it. Interactive lookups run at HIGH priority, the worker at LOW.
Answers are parsed in services/lyrics/parse/ (TTML, enhanced LRC, KRC, LyricsPlus JSON and Lyricsfile YAML) into ParsedLyrics by LyricsAssembler, which sanitises text, enforces the line and word limits and keeps word timing per line only when every word of the line is timed and the words spell the line text. TrackMatcher compares the title, artist and duration a provider reports with the song. A mismatch is stored as a miss.
Ranking orders candidates by: manual first, then sidecar, then the rest, then sync level (word before line before plain, an instrumental answer counts as line), then whisperx after online candidates of the same level, then VERIFIED before PROVIDER, then the position in LYRICS_PROVIDERS, then the smaller duration gap. The uncensored candidate ranks as its source and directly above it. The default candidate is the one pinned through SongTable.preferredLyricsCandidate, otherwise the best ranked one. Ranks are written to the rank column after every change, and every write announces the song through EntityEventPublisher.
Masked words (****, n***a, mother****er, f--k, [__]) of the best ranked stored candidate that is not manual or sidecar are restored from the other candidates of the song by LyricsUncensor (services/lyrics/parse/). It aligns the tokens of both versions globally, with line starts at most 5 s apart when both are timed, and restores a masked word only when the donor word fits the visible letters and every unmasked neighbour within the line, at least one, matches the adjacent donor word exactly. Words are compared after a small alias map of common lyric spellings is applied (tryna counts as trying to, ya as you, 'cause as because, multi-word forms are expanded into several tokens and never inserted in part) and dropped g endings (nothin') count as ing. When a mask fits several donor words, the one as long as the mask wins. Donors are tried in rank order. The result is stored as the uncensored row, whose source_candidate points at the censored row, after every store and setSyncedLyrics. It is deleted when nothing is left to restore, and a pin on the source moves to it when it is created. The custom migration RestoreMaskedLyricsWithAliases builds it for candidates stored before.
An interactive lookup (findLyrics, streamLyrics, findLyricsByQuery) asks every provider in parallel with a 12 s timeout. Providers that have not answered by then are returned as pending and nothing is stored for them.
A provider is asked again when:
- it was never asked,
- its error is older than 3 days,
- its miss is older than 14 days, or
- its line synced answer is older than 14 days (a later line answer only refreshes the time, a word answer replaces it).
A failed lookup turns a missing row or a miss into an ERROR row and refreshes the time of an existing error, and a found answer stays unchanged with only a new fetch time. refresh asks every provider regardless. The sidecar reader is asked on every lookup: it reads <audio name>.lyricsfile.yaml, .ttml and .lrc next to the audio file (in that order, at most 256 KiB), and a vanished sidecar file removes its candidate.
LyricsProviderWorker (nightly, 0 5 * * *) takes the songs LyricsCandidateService.songsDue returns, which applies the same retry rules as a lookup, and calls findLyrics at LOW priority for each without the 12 s deadline, so every provider either answers or fails through its own HTTP timeout and gets a row. LyricsSyncWorker skips songs that already have a found word synced candidate. Song lists carry a SongLyricsInfo summary built by lyricsInfoFor in one grouped query per chunk, searchByLyrics also searches the plain text of found candidates, and Subsonic getLyricsBySongId serves the default candidate when it is synced. A remote mirror copies only the remote song candidate into SongTable.lyrics, written as LRC when it is synced.
Proxy tunnel protocol
The standalone :proxy and the server share one binary protocol on the websocket that the server opens to /proxy/server. The code is in common-proxy/src/main/kotlin/dev/dertyp/proxy/ (Protocol.kt, HttpTunnel.kt) and is used by :proxy (Application.kt, HttpTunnelRoute.kt) and by the server (services/ReverseProxyService.kt, services/ReverseProxyHttpGateway.kt). The request flow and the path scope are in ARCHITECTURE.md.
Every frame is binary: [u8 type][16-byte id][u8 subType][payload]. The id is the client id of a kRPC connection or the stream id of an HTTP request. Numbers are big endian.
| Type | Message | subType | Payload |
|---|---|---|---|
| 0 | NewClient |
0 | JSON {uri, headers} |
| 1 | ClientFrame |
1 for a binary websocket frame, else 0 | the frame bytes |
| 2 | ClientDisconnected |
0 | none |
| 3 | AssignedId |
0 | the instance id as UTF-8 text |
| 4 | Ping |
0 | none |
| 5 | Pong |
0 | none |
| 6 | HttpRequest |
bit 0 set when the request has no body | [u8 methodLength][method, ASCII][u32 uriLength][uri, UTF-8][headers] |
| 7 | HttpResponse |
bit 0 set when the response has no body | [u16 status][headers] |
| 8 | HttpBody |
bit 0 set on the last chunk | up to 65536 bytes of body |
| 9 | HttpReset |
the reason | none |
| 10 | HttpWindow |
0 | [u32 increment] |
headers is [u16 count] followed by count entries of [u16 nameLength][name][u32 valueLength][value]. A name with several values is sent as several entries. The proxy removes hop-by-hop headers (Connection and the headers it names, Keep-Alive, Transfer-Encoding, Upgrade and the like) in both directions, and Host, Expect and HTTP2-Settings from requests. The uri is the request target without the instance id prefix. A frame that cannot be decoded is dropped.
A header value travels in the frame as text with one character per byte of the header line, written as UTF-8. Netty reads header bytes that way, and the gateway converts on both sides of its loopback client, which reads and writes header text as UTF-8. A value whose bytes are valid UTF-8, plain ASCII included, therefore has the same bytes with and without the proxy, in requests and in responses. A value whose bytes are not valid UTF-8 is not byte-exact: in a request every byte from 0x80 on arrives at the server as the two bytes of its UTF-8 form, and in a response the bytes that are not UTF-8 reach the client as ?. The file name in the Content-Disposition header of the file routes is not such a value: the header is plain ASCII, with the exact name percent-encoded in filename*, so it has the same bytes with and without the proxy. ReverseProxyRestEndToEndTest compares request header values and that file name header with direct access.
HttpReset reasons: CANCEL (0), REFUSED (1, no free stream), INTERNAL (2), PROTOCOL (3, a peer broke the rules of the protocol) and HEAD_TOO_LARGE (4). An unknown code reads as INTERNAL. The proxy turns REFUSED into 503, HEAD_TOO_LARGE into 431 and every other reset before the response head into 502.
| Limit | Value | Constant in HttpTunnel |
|---|---|---|
| Body chunk | 65536 bytes | BODY_CHUNK_SIZE |
| Initial window per stream and direction | 262144 bytes | INITIAL_WINDOW |
| Window grant threshold | 65536 bytes | WINDOW_GRANT_THRESHOLD |
| Request head or response head | 65536 bytes | MAX_HEAD_SIZE |
| Open HTTP streams per tunnel | 128 | MAX_CONCURRENT_STREAMS |
Flow control is a credit per stream and per direction, so a slow reader of one response holds back only that stream. The sender of body bytes may send as many bytes as it has credit, starting with the initial window (HttpSendWindow). The receiver counts the bytes that arrived against its window (HttpReceiveWindow). A frame that exceeds the window is a protocol violation and resets the stream with PROTOCOL. Once the receiver has handed at least the grant threshold of bytes on to its consumer, it sends an HttpWindow frame with that number. A grant that would overflow the credit is a violation too. The head, the empty last HttpBody and the reset frames cost no credit. kRPC frames (ClientFrame) have no credit. The proxy queues them per client without a bound, so a kRPC client that stops reading costs memory on the proxy and does not delay the HTTP streams of the same instance.
Capability: the server adds http=1 to the query of its /proxy/server registration. A proxy that does not know the parameter ignores it and tunnels kRPC only. A proxy that knows it and receives a registration without it answers HTTP requests for that instance with 501. A server never sends an HTTP frame unless the proxy sent one first, so an older server that does not know the types is not affected.
Operational notes:
- A reverse proxy in front of the Synara proxy (nginx and similar) must not buffer responses, or SSE events are delayed.
- The proxy disconnects a client that reads nothing for 10 seconds while data is pending (the response write timeout of Netty).
- There is no environment variable for any of this.
Dependency updates
check_updates.py (repo root) reads gradle/libs.versions.toml, checks Maven Central and JitPack for newer compatible versions of each dependency, and prints key=version lines for anything outdated:
python3 check_updates.py
Review the output (redirect it to a file such as updates.txt if that's easier to work through) and apply the version bumps you want to gradle/libs.versions.toml by hand.
Troubleshooting
- Build fails with
common-rpcsources missing: the repo was cloned without submodules; rungit submodule update --init --recursive. - CI fails on "Verify generated docs are current": a change touched RPC interfaces, REST routes, or environment variables without regenerating the corresponding doc; run the generator locally (see Generated files) and commit the diff.
- PostgreSQL-backed tests fail immediately with a container error: Testcontainers couldn't reach Docker; make sure Docker is running and
DOCKER_HOSTis unset or points at a working daemon (CI sets it tounix:///var/run/docker.sock). ./gradlew assemblefails: it also builds everycommon-rpcnative target (iOS, tvOS, macOS, Windows, Linux), which needs toolchains most machines do not have; use./gradlew testor./gradlew :server:shadowJarinstead.