Skip to content

Repository files navigation

Tocata

An OpenSubsonic compatible music server written in Rust.

Tocata focuses on the server side of the API. There are already plenty of good players that speak OpenSubsonic, so building another one is not the goal: the only interface Tocata ships is a small administration panel.

What ships is a single binary with the panel inside it, statically linked and bringing its own libc. There is nothing to install beside it, no runtime to provide and no web server to put in front of it.

Status

Early days, and now being run in earnest to find out what is still wrong. There are no releases yet, so what there is to run comes from the last build of the master branch.

Running it

The container image

Published on every push to master:

podman run -d --name tocata \
  -p 4224:4224 \
  -v tocata-data:/data \
  -v /srv/music:/media:ro \
  ghcr.io/ogarcia/tocata:master

docker in place of podman works the same way. Two directories matter: /data is Tocata's own — the database lives there — and /media is where the music is. Mounting the music read only is not a precaution against Tocata, which never writes to a collection; it is simply true, so the filesystem may as well say so.

Nothing is scanned for having been mounted: /media, or a directory inside it, is added as a collection in the panel afterwards. Which is deliberate — somebody who keeps their music beside their audiobooks wants two collections with different people reaching each, and one mount cannot say that.

The image runs as uid 1000 and never as root. A named volume for /data, as above, is owned correctly from the start; a bind mount from the host arrives with the host's ownership and has to be writable by that uid, or --user has to name another.

The image can also be built from a checkout, which compiles everything inside it and needs nothing installed but the container tool:

podman build -t tocata .

A prebuilt binary

Every build of master uploads one per architecture. With the GitHub CLI, where the run has to be named because gh run download takes a run and not a branch:

run=$(gh run list --repo ogarcia/tocata --branch master --workflow CI \
        --status success --limit 1 --json databaseId --jq '.[0].databaseId')
gh run download --repo ogarcia/tocata "$run" --name tocata-amd64
chmod +x tocata

tocata-arm64 is the other one. They can also be downloaded from the summary page of any run under the repository's Actions tab, which needs a GitHub session — artifacts are not public downloads. The executable bit is set by hand above because a zip file does not carry permissions.

Both are statically linked against musl and bring their own libc, so they run on any Linux of the right architecture, distribution and version regardless.

From source

Rust, the WebAssembly target and trunk — which builds the panel — are what it takes:

rustup target add wasm32-unknown-unknown
cargo install --locked trunk

The panel is built first, because the server carries the result inside its own binary and will not compile without it:

cd panel && trunk build --release && cd ..
cargo build --release

What comes out is target/release/tocata, linked against the system's libc. For the static build the images and the artifacts ship, add the musl target and name it — on a Debian or Ubuntu machine musl-tools is needed as well, since SQLite is compiled from source and its compiler has to target musl too:

rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

The first run

Tocata creates its data directory and its database on the way up, and makes one administrator to get in with. That account's password is generated and written to the log exactly once:

WARN tocata::user: initial password for 'admin': …

Then the panel is at http://localhost:4224/. Collections are added there, one row each, and adding one reads nothing by itself: the scan is started from the panel's first screen, which is also where it reports what it found while it runs.

Configuration

Everything about the deployment is read from the environment. Everything about the music — which collections there are, who may see them, how the catalogue reads — belongs to the panel, because it outlives a restart and a container's command line does not.

Variable Default What it is
TOCATA_DATA_DIR data Where the database and the caches live. The one directory Tocata writes to.
TOCATA_PORT 4224 Port to listen on. A port that is not a number stops the server rather than being ignored.
TOCATA_LIBRARY_PATHS Collections to make sure exist, separated by colons as in PATH. Read on every start; it adds and enables, and never removes.
TOCATA_IGNORED_ARTICLES The El La Los Las Le Les Seeds the articles dropped when filing artists by letter. First run only — after that the panel owns it.

What answers where

Path
/ The administration panel
/rest The OpenSubsonic API, which is what a player wants
/api/v1 The panel's own API
/api/docs Reference for that API, generated from it
/api/health Whether the server is up and its database answers

Authentication

There are two doors and they work differently, because a browser and a music player are not in the same situation.

The panel

A username — or the account's email address — and a password, exchanged for a session. The session is a row in Tocata's own database: 256 bits from the system's random number generator, kept as a SHA-256 digest, so what is stored is not the token that opens it. The token travels in a cookie named tocata_session, scoped to /api, HttpOnly and SameSite=Strict. HttpOnly keeps it out of reach of the panel's own scripts, and so of anything injected into the page; it is also what makes the event stream work, since EventSource cannot set a header and a bearer token would have nowhere to go.

None of this needs a secret configured, because nothing is signed. A signed token — a JWT — is a way of not looking anything up, and it earns its keep when the lookup is the expense; here every request reaches SQLite anyway, and this particular lookup is a unique index on a digest. What a signature would cost is the part that matters: a token nobody can take back. The panel lists the sessions an account has open and can close the rest, and changing a password closes them — with a signed token that needs a list of the ones no longer welcome, which is precisely the state the signature was avoiding. The secret itself is the other cost: one more thing to generate, keep and hand to a container, where losing it logs everybody out and leaking it lets anybody issue sessions.

How long a session lasts is a setting rather than an environment variable, thirty days to begin with, and it is absolute rather than sliding: shortening it applies to the next login and leaves the people already inside where they are.

Keeping the login is what adds Max-Age to the cookie. Without it the browser drops the cookie when it closes while the row keeps its own expiry — what has been forgotten is the way back in, not the session. Logging out ends the row, and only that one: the other browsers stay logged in.

The API

A client under /rest proves who it is with a username and a password, u and p, the latter either plain or as hex behind enc:, which the protocol allows and which conceals nothing, being reversible by anybody.

Or with an API key, from the apiKeyAuthentication extension. Keys are made in the panel, each with a label to tell it from the others, and each can be withdrawn on its own without disturbing the rest. The extension is explicit that a key travels alone, so a request carrying apiKey alongside any of u, p, t or s is refused with error 43.

A key is also accepted where a password goes, because a client's login screen has one box for a password and usually no field for anything else — so a key pasted into it works. Only the key belonging to the account named in u, though: somebody else's opens nothing, and rather than being refused outright it falls through to the password check, since what was offered may still be this account's password. Keys are for players and not for the panel, which takes a password.

Token and salt, and why not

The mechanism the specification calls token authentication — s and t=md5(password + salt) — is refused with error 42, "provided authentication mechanism not supported", and cannot be anything else.

Verifying that token means computing md5(password + salt) here, which means having the password to hand: in clear, or encrypted beside the key that decrypts it, which is the same thing the day somebody copies the database. What Tocata keeps is an Argon2id hash, and the whole purpose of one is that the password cannot be got back out of it.

So this is not a feature nobody has written yet. It is a mechanism that cannot coexist with storing passwords properly, and between the two the passwords win. Error 42 is the specification's own way of saying a server does not offer a mechanism, and every client we have tried has a setting for the other way — worded as plain, clear text or legacy password authentication, depending on the client — which is what Tocata answers.

What Tocata does not answer

Audio is the whole of what this server is for, and the OpenSubsonic API covers more than audio. What follows is the part it does not do, and why.

getOpenSubsonicExtensions is the canonical answer to this question — a client asks it rather than reading documentation — and Tocata declares four extensions there: apiKeyAuthentication, songLyrics, topSongsByArtistId and playbackReport.

Answered in the protocol, with nothing behind them

These are registered, because an HTTP 404 does not tell a client that a server lacks a feature from one that is broken or behind a misconfigured proxy. A listing comes back empty, and anything naming one particular thing comes back as error 70.

Endpoints Why
getVideos, getVideoInfo, getCaptions, hls.m3u8 Tocata plays audio. Video is another program's job.
getSimilarSongs, getSimilarSongs2 Deciding that two songs are alike takes data this server does not have. Guessing from genres would be inventing recommendations and calling them knowledge.
getShares Sharing means serving audio to somebody who has not logged in, and a database to remember what was shared with whom. It is a large piece of work whose subject is access, not audio.
getPodcasts A podcast is a feed to fetch and files to download, not the collection somebody chose. There is no shortage of applications that do it well.
getInternetRadioStations The same: a station is a URL somebody else is serving, and players for those are their own thing.
getChatMessages, addChatMessage A music server is not a chat room.
getAvatar There are no avatars anywhere in Tocata to serve.

Not answered at all

Endpoints Why
createShare, updateShare, deleteShare As above: access rather than audio, and a large change to earn it.
createPodcastChannel, deletePodcastChannel, deletePodcastEpisode, downloadPodcastEpisode, getNewestPodcasts, getPodcastEpisode, refreshPodcasts Fetching and downloading somebody else's feed.
createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation Keeping a list of other people's stream URLs.
jukeboxControl Playing music out of the speakers attached to the server. That needs an audio device and a decoder inside a binary that is meant to have neither.
getTranscodeDecision, getTranscodeStream Tocata serves files as they are on disk. There is no transcoder in it.
findSonicPath, getSonicSimilarTracks Analysing what the music sounds like, which is a field of its own.
getPlayQueueByIndex, savePlayQueueByIndex The indexBasedQueue extension, which exists to say which copy of a repeated track is playing. No known client asks for it, and getPlayQueue and savePlayQueue are both answered.

Releases

Packages

Contributors

Languages