Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

BeetstreamNext logo

BeetstreamNext

BeetstreamNext exposes your Beets music library over the OpenSubsonic API, letting you stream your music to any Subsonic/OpenSubsonic/Navidrome-compatible client.

It supports all of the Subsonic/OpenSubsonic API specification, including its extensions. Only the video-streaming functionalities are excluded (see API coverage).

BeetstreamNext also provides metadata augmentation beyond your Beets library (artist biographies and pictures, Discogs ratings, etc…) and a few additional features and tweaks.

See Features for the full detailed view.

Docs overview

Project history

BeetstreamNext originated as a fork of Beetstream. It has since fully separated and shares almost no code with the original, but it’s still worth crediting :)

License

BeetstreamNext is MIT-licensed. See the LICENSE file for details.

Features overview

Zero-file-modification by default

BeetstreamNext is designed for people who manage metadata inside Beets but don’t want tags written back to disk. Every setting that would write something to your files/disk defaults to off, and disk writes in general are additionally gated behind the allow_disk_writes setting.

OpenSubsonic API: Full coverage

BeetstreamNext implements (almost) all of the Subsonic/OpenSubsonic REST API, including its extensions.

The only endpoints it doesn’t support are the video-related ones (BeetstreamNext is an audio server, and Beets doesn’t manage video anyway). See API coverage for the full endpoint-by-endpoint checklist, and Tested clients for apps confirmed to work.

BeetstreamNext’s additional features

AreaAdditions over BeetsMore details
StreamingDirect play, on-the-fly transcoding, adaptive-bitrate HLS, server-side ReplayGain, self-healing streamsStreaming & audio quality
Metadata & artworkArtist bios, top tracks, similar artists/songs, album/artist ratings, lyrics, album editions, sonic similarityLibrary augmentation
Multi-userMulti-user auth, user roles, bookmarks/ratings/favorites/queues that sync across devices, chat featuresAccounts, permissions & chat
Non-library audioInternet radio stations, podcast subscriptions & downloadsRadio & podcasts
ScrobblingSend your listens to ListenBrainzScrobbling
PlaylistsBeetstreamNext’s own per-user playlists, and read/write access to Beets’ playlist/smartplaylist filesPlaylists
SharingPublic landing pages and download links (no client or account required)Sharing
SecurityIP allow/deny lists, adaptive rate-limitingSecurity
Jukebox modeOpenSubsonic’s jukebox mode, augmented by Sonos and Chromecast supportJukebox mode
Beets importBetanin-like interactive or non-interactive imports (also triggerable from Subsonic clients) and watched-folder auto-importBeets import
NotificationsApprise notifications (Discord, Telegram, ntfy, email, webhooks, etc) when imports start, finish, fail, or need inputNotifications
Advanced Beets queriesPrefix any search from your client app with beets: or b: to run a complex Beets query instead of simple searchLibrary augmentation

External data sources

Additional data and library augmentation comes from third-party services (they are all entirely optional):

DataSourceEnabled by
Artist biographyLast.fm (if lastfm_api_key is set), or Wikipediafetch_artists_biographies
Artist top tracks & similar artists/songsLast.fmlastfm_api_key
Artist imagesDeezer’s public search APIfetch_artists_images
Missing album artCover Art Archivefetch_artists_images/save_album_art
Album/artist community ratingDiscogs’ public ratingdiscogs_ratings
Album edition/version info (e.g. “Deluxe Edition”)MusicBrainzfetch_album_version
Acoustic similarity & playlist path-findingAudioMuse-AI (your own self-hosted instance)audiomuse_url and audiomuse_api_token
Radio station / podcast faviconsThe station/feed’s site, falling back to DuckDuckGo’s icon proxyautomatic, when an icon isn’t supplied
Internet radio station discoveryRadio Browserenable_radio_discovery
Podcast channel discoveryPodcast Indexenable_podcast_discovery
Song LyricsUses Beets’ lyrics plugin’s configured external sourcesfetch_lyrics/save_lyrics

Important

None of these ever get written into Beets or your files, unless the matching save_* setting is also enabled. See Library augmentation for more detail.


See the configuration reference for any of the settings mentioned here.

Library augmentation

A lot of what shows up in your Subsonic client (artist photos, biographies, community ratings, “songs like this one”, etc) isn’t in your Beets library: BeetstreamNext collects and serves additional data.

Metadata enrichment

  • Artist biographies: From Last.fm if lastfm_api_key is set, otherwise from Wikipedia if fetch_artists_biographies is enabled.

  • Artist top tracks and similar artists/songs: From Last.fm, using lastfm_api_key.

  • Album/artist ratings: Either your local users’ ratings, or Discogs’ public community rating via discogs_ratings (off / fallback / prefer).

  • Lyrics: Served from Beets’ stored lyrics if present, from .lrc files next to the audio files (tip: use the filetote plugin), or fetched on-the-fly using Beets’ lyrics plugin (fetch_lyrics), optionally written back into Beets with save_lyrics.

  • Album version/edition info (e.g. “Deluxe Edition”, “Japanese Expanded Edition”): From MusicBrainz, via fetch_album_version, optionally saved with save_album_version.

Artwork

  • Album art / artist images are served from your Beets library’s local art path when available.

  • When missing:

    • Album art from Cover Art Archive.

    • Artist images from Deezer’s public search API.

    • Controlled by fetch_artists_images.

    • save_album_art allows saving album art next to the music files.

    • follow_playlist_embedded_urls lets cover art be pulled from a foreign playlist’s #EXTALBUMARTURL lines, for albums that have no local art. (only enable for playlist sources you trust!)

Sonic similarity

To expose OpenSubsonic’s sonicSimilarity extension, BeetstreamNext integrates with AudioMuse-AI, a self-hosted audio-analysis service. This is used for acoustic similarity (based on the audio, not tags or genre), to find similar songs, and path-finding a playlist between two tracks based on their audio embeddings.

Point audiomuse_url at your AudioMuse-AI instance and set audiomuse_api_token to enable it. The admin panel can trigger a full-library fingerprinting run on AudioMuse-AI directly.

Advanced search: Beets query passthrough

Any search request can be turned into a raw Beets query by prefixing it with beets: or b:, for example b:length:..3:30 to find tracks under 3:30. This runs Beets’ own complex query DSL (regex, field-specific, fuzzy matching) straight from your client’s search bar, instead of a normal search.


See the configuration reference for any of the settings mentioned here.

Accounts, permissions & chat

Authentication

Both authentication schemes from the Subsonic/OpenSubsonic spec are supported:

  • API-key authentication: This scheme is always supported and is the recommended one. See app passwords.

  • Legacy MD5-token / cleartext password authentication: For older clients. Can be enabled server-wide via legacy_auth if you need it. It only accepts app passwords created with Usable as a password.

Important

Subsonic clients need an app password: the account password is only for the Web UI.

Multi-user system

Every user account has their own bookmarks, ratings, favorites, play statistics, play queues, and internet radio stations, all synced across whatever devices/clients they use.

BeetstreamNext stores this data separately from your Beets library, since Beets itself has no concept of multi-user data.

You can, however, have one user’s likes/ratings persist inside the Beets library so they survive outside BeetstreamNext and are visible to other Beets plugins/tools: set ratings_writeback_user to that username.

User roles

Users permissions are as defined by the OpenSubsonic/Subsonic spec’s roles. These roles can be set from the admin panel’s Users tab (or --update-user/update-user on the CLI):

RoleDefaultAllows
AdminoffFull access to the admin panel and all settings
SettingsonChanging their own personal settings and password
StreamonPlaying files
DownloadoffDownloading files
PlaylistsonCreating and deleting their own playlists
CommentsonCreating and editing comments and ratings
PodcastsoffManaging their own podcast subscriptions
SharingoffGenerating public shares (see Sharing)
JukeboxoffControlling jukebox-mode playback (see Jukebox)
ScrobblingonKeeping track of what they listen on BeetstreamNext (and/or sending to extrernal scrobbling account)

A new user account starts with the defaults above unless overridden at creation.

Note

A few roles from the spec (coverArtRole, uploadRole, videoConversionRole) are exposed for client/spec compatibility but do currently nothing in BeetstreamNext.

User account page

Users with the Settings role can sign in at /login, and can manage their avatar, email, password, app passwords, and internet radio stations, and (if they are allowed) their public shares and podcast subscriptions.

Important

Admins who sign in on a listener without the admin scope (see admin_hosts and admin_port) see the non-admin view of their account.

Chat

BeetstreamNext implements the standard Subsonic chat endpoints (getChatMessages/addChatMessage). Any client with a chat feature can use it as a simple message board.

In addition, you can post server announcements from the admin panel, and edit or delete any user’s chat message for moderation. See Web UI usage.


See the configuration reference for any of the settings mentioned here.

App passwords

App passwords are credentials you create for the Subsonic clients (phone app, desktop player, speakers…).

They can be:

  • used as the API key (apiKey), which is the recommended way
  • used as the password of a legacy login (p, or t+s tokens), if you tick Usable as a password when creating it and legacy_auth is enabled.

By default an app password is API-key only (so that the server can just keep a hash of it). Legacy Subsonic logins require the full secret to be stored (to check the t+s token).

Creating one

  • Web UI: on your account page, or in the admin panel’s Connections tab. The secret is shown once:, copy it.
  • Command line: see create-app-password.

Access levels

AccessAllows
Full accessEverything (whihin the user’s capabilities)
Read onlyBrowsing, searching and streaming. Anything that changes data is refused with Subsonic error 50

Notes

  • Secretes that are also useable as password need to be stored in full (encrypted with the server key BEETSTREAMNEXT_KEY).

  • Changing your account password does not revoke your app passwords.

  • Wrong app passwords count towards the rate limiter like any failed login.

  • Secrets are never shown again, never logged, and never part of the account data export (only the name, access, and dates are).

  • Upgrading from a BeetstreamNext version with a single API key per user: that key becomes an app password named Default API key with full access. It only works as an apiKey (not as a legacy password), since its secret was never stored in a recoverable form.

Important

Your account password is only for the Web UI.

If you are upgrading, every client that used your account password now needs an app password.

Streaming & audio quality

Direct play & transcoding

BeetstreamNext serves files directly if a client requests them as-is, or transcodes on the fly via FFmpeg otherwise (bitrate limit requested by the client, unsupported format, etc.).

You can set never_transcode to always stream the original file regardless of what the client asks for.

Direct play means fully lossless, bit-perfect streaming: FLAC, ALAC, and other lossless formats are sent to the client exactly as they sit in your library.

Adaptive-bitrate HLS

For clients that request it, BeetstreamNext supports OpenSubsonic’s HLS spec and can serve AAC HLS with adaptive bitrate, allowing the client’s player switch quality on the fly as network conditions change.

ReplayGain

BeetstreamNext can apply ReplayGain normalization server-side during transcoding, independent of whether the client itself supports ReplayGain or not:

  • replaygain_enabled turns this on.

  • replaygain_preamp adds extra gain (dB) on top of the tag value.

  • replaygain_fallback is the gain applied to tracks that have no ReplayGain tag in Beets.

  • audio_peak_limit prevents the result from clipping (peaks capped at 0 dB) regardless of the gain applied.

Error detection & healed streams

A background scan probes files for decode errors. If a track is flagged, it is automatically routed through a transcode pass instead of direct play, so a corrupt/broken file doesn’t just fail to play.

This healing pass keeps the original container/codec (when the client doesn’t ask for something else).

A healed lossless file is re-encoded losslessly rather than streamed byte-for-byte.

Reverse-proxy file offloading

Direct (non-transcoded) file serving can be offloaded to the reverse proxy instead of streaming bytes through the Python process, via X-Accel-Redirect (Nginx) or X-Sendfile (Apache).

See Reverse proxy & CORS for how to setup.


See the configuration reference for any of the settings mentioned here.

Playlists

BeetstreamNext manages its own playlists and also mirrors those from Beets:

  1. BeetstreamNext’s own playlists: Created via the standard Subsonic playlist endpoints, and stored in BeetstreamNext’s database. Any .m3u files already present in playlist_dir (per-user subfolders are considered private playlists, top-level files are considered public) are imported on startup.

    Every change is also written to an .m3u file in playlist_dir so other players can use them. Leave playlist_dir setting empty to not write any.

  2. Beets’ playlist plugin directory: Regular .m3u-style playlist files managed by Beets. They are considered public and kept in sync with their files. Edits made in BeetstreamNext are written back to the file.

  3. Beets’ smartplaylist plugin directory: Query-generated playlists. These are read-only in BeetstreamNext, because they’re automatically regenerated by Beets.

Permissions

  • Any user with playlistRole can create/edit/delete their own BeetstreamNext-native playlists.

  • Private playlists can only be seen and edited by their owner (and admins). Public playlists can be edited by everyone.

  • Admins and users listed under external_playlists_editors can also edit/delete playlists from the Beets playlist plugin directory. Playlists generated by smartplaylist plugin are read-only.


See playlist_dir and external_playlists_editors in the configuration reference.

Moving playlists to a streaming service

If you want to export your playlists to Spotify, Deezer, Tidal and others, you can:

  1. Export as CSV from the Web UI, and upload it to Tune My Music

  2. Connect BeetstreamNext directly to Soundiiz using the OpenSubsonic connection

Both can then export to most existing streaming services.

Scrobbling

You can send what you listen to in BeetstreamNext to external listening-history services. Supported services are ListenBrainz, Last.fm and Libre.fm.

To scrobble, a user needs the Scrobbling role (it is on by default), and the external_scrobbling setting needs to be enabled server-wide.

Connecting ListenBrainz

  1. On ListenBrainz, copy your user token from your settings page.

  2. In BeetstreamNext, open your account page (or the admin panel’s Connections tab) and paste the token in the Scrobble card.

Connecting Last.fm or Libre.fm

These need the lastfm_api_key and the lastfm_shared_secret settings, set by an admin. Libre.fm uses the same ones.

Note

Last.fm only shows the shared secret once when the API key is created. If you didn’t save it, create a new API key (leave the callback URL empty) and use its new key and secret.

Each user:

  1. In the Scrobble card, click Connect on the service.

  2. Click Open Last.fm, and approve access to your account on their site.

  3. Come back to BeetstreamNext and click I’ve approved it.

Your password is never seen by BeetstreamNext, only the session key returned by Last.fm is stored (encrypted).

Using multi-scrobbler

multi-scrobbler can collect your listens and forward them to many services at once (Last.fm, Maloja, Koito, etc.). There are two ways to use it with BeetstreamNext:

  • ListenBrainz endpoint: in multi-scrobbler, add a ListenBrainz endpoint source, then in the Scrobble card here set the URL to your multi-scrobbler address (for example http://multi-scrobbler:9078) and use the token from that source.

  • Subsonic source: in multi-scrobbler, add a Subsonic source pointing at your BeetstreamNext server with your username and an app password. Nothing to set up here, but multi-scrobbler works by polling the “now playing” endpoint, so play times are less precise.

Important

With the ListenBrainz endpoint on a self-hosted multi-scrobbler instance, you need to allow BeetstreamNext to reach it on your private network: see Services on your local network.

What is sent

WhenWhat
A track starts (client sends a “now playing”)Artist, title, album
A track is finished (client sends a scrobble)Same as above, plus the time you played it, and the track length

If the track has MusicBrainz IDs in your Beets library (recording, release, artist), they are sent too, so ListenBrainz can match the track exactly. BeetstreamNext also identifies itself as the submitting client.

Note

Radio streams, podcasts, and tracks shorter than 30 seconds are not scrobbled.

Security

Access controls

  • IP allow/deny lists: ip_whitelist/ip_blacklist, they accept individual IPs or CIDR ranges.

  • Listener scopes: each listener has a scope (public, users or admin) that sets which routes it serves, based on the address and port it listens on. See Listener scopes.

    • public_hosts and public_port give listeners the public scope (public routes).

    • admin_hosts and admin_port give listeners the admin scope (admin panel and control API).

    • The other addresses have the users scope.

    • restrict_control_api (on by default) keeps the control API on admin scope listeners. Turn it off to also serve it on users ones (it is still protected by its token).

    • Remark: By default, a single listener serves everything.

  • Trusted Host headers: trusted_hosts controls which Host header values are accepted.

    • Remark: If the server is reachable beyond your own machine, it is recommended to set this. You can also use external_hostname for share links.

    • A Host header that isn’t just a hostname[:port] (scheme, path, user@, spaces, control characters…) is always refused (with HTTP 400).

  • Legacy authentication: legacy_auth controls whether the older MD5-token/cleartext Subsonic authentication mode is accepted. Many clients still need it.

    • Remark: API-key authentication is always available regardless of this setting, so clients that support apiKey can use an app password with this turned off.

    • Remark: The Subsonic protocol sends credentials in the URL query string, so they can end up in reverse-proxy access logs. See Credentials in proxy logs.

  • App passwords: clients should use app passwords, which can be revoked, limited to read-only access and set to expire, instead of the account password.

  • User management over the API: api_user_management controls whether admins can create, update and delete users, and change other users’ passwords, through the Subsonic API. It is off by default. The admin panel is not affected.

Security logging and CrowdSec

Auth failures, rate-limit blocks and IP-filter denials are logged as BSN-SEC event=... ip=... endpoint="..." user="..." lines (credentials not included, usernames sanitised) and a ready-made CrowdSec parser and scenarios are available in the crowdsec/ directory. Remember to set up reverse_proxy so the logged IPs are the real client ones.

Beets config protection

The Beets config can load plugins and run commands, so editing it from the admin panel has some restrictions:

  • allow_beets_config_edit must be enabled before server runs (it can’t be changed from the Web UI).

  • Code-execution settings are blocked: even when editing is on, the panel refuses to add or modify the inline and hook plugins, pluginpath, item_fields/album_fields, and options holding a command or binary (convert.command, play.command, keyfinder.bin, etc).

    Settings already present in the file are honoured as long as they are unchanged.

    See Blocked settings for the full list.

  • Disk writes need opt-in: imports that would modify files (tags, copy/move, or plugins such as scrub, embedart or fetchart in auto mode) require allow_disk_writes.

    See Disk writes.

Note

These are guard rails against a compromised or careless admin session, not a sandbox. Someone who has write access to the config file on the server can obviously still change them.

Adaptive login rate-limiting

Two independent buckets slow down brute-force attempts:

  • Per-pair of (IP, username): rate_limit_max_failures failed attempts within rate_limit_block_window seconds blocks that specific (IP, username) pair.

  • Per-IP: rate_limit_max_failures failed attempts from an IP (using any username) within rate_limit_block_window seconds blocks that IP. This catches attackers rotating usernames to dodge the per-user limit above.

  • Tokenised URLs (the unauthenticated stream URLs handed to Sonos/Chromecast speakers, and the image links in artist/album info) stop working after 12 hours (streams) or 24 hours (images) without being used. A speaker playing a queue or a client re-fetching an image keeps its link alive. Stale links return 404, and the client gets a fresh one the next time it asks.


See the configuration reference for any of the settings mentioned here.

Services on your local network

To prevent users from making the server contact things it should not (a request forgery, or SSRF), BeetstreamNext restricts connections to private addresses (localhost, 192.168.x.x, Docker container names, etc.).

When you need BeetstreamNext to reach a self-hosted service (for example a multi-scrobbler instance, an admin can whitelist it in the local_services setting (hostnames, IP addresses or CIDR ranges are accepted).

  • A hostname is trusted whatever address it resolves to (convenient for container names).

  • An IP or a range is trusted by address.

  • Ports are not restricted.

  • The whitelist only applies to services that an admin or user configures, like external scrobbling. Simple fetchable URLs like podcast feeds, radio streams, images, always stay restricted to public-only.

Public shares

Users with the shareRole permission can generate public share links for a song, album, or playlist.

BeetstreamNext creates a public landing page with a secure download endpoint, accessible without needing the recipient to have an account or Subsonic client.

Active shares can be revoked at any time from the admin panel’s Shares tab.

Share options

Shares can be configured with an expiry date, a download count limit, and protected by passwords.

You can set a default policy for these (since OpenSubsonic clients can’t set a password or download limit).

Public routes

Everything that works without an account is a public route:

  • share pages and downloads (/share/...)
  • tokenised stream and image links (/tokenised-stream/..., /tokenised-image/...)
  • the now-playing cover (/now-playing/cover)
  • the health check (/healthz), the homepage (/) and the static files they use

Everything else (the Subsonic API, login, account pages, admin panel, control API) needs an account.

Public hostname

You can expose the public shares on a different hostname than what BeetstreamNext binds to locally:

For example, you access the admin panel via https://admin.example.com and the Subsonic API and account pages via https://music.example.com, but you want to allow unauthenticated users to download public shares from https://public.example.com.

You can set external_hostname so that share links use the correct public URL instead of the internal bind address:

beetstreamnext:
  external_hostname: public.example.com

If it isn’t set, share links use the address of the public scope listener (or the users one), when it has a specific address (not 0.0.0.0 or loopback) and when reverse_proxy is off. Otherwise, they use the Host header of the request, which is only as trustworthy as trusted_hosts.

Set external_hostname whenever the server is reachable from outside.

Listener scopes

Every listener of BeetstreamNext has a scope that sets which routes it serves. Any other route returns 404.

ScopeServes
publicthe public routes
usersthe public routes, the Subsonic API, login and account pages
admineverything, including the admin panel and the control API

By default, a single listener has the admin scope. To give addresses or ports a smaller scope, use:

By address: public_hosts and admin_hosts

On a machine with several addresses, public_hosts and admin_hosts pick the ones that serve the public and admin scopes. They must also be in host. The remaining addresses have the users scope (or admin, if neither admin_hosts nor admin_port is set):

beetstreamnext:
  host: [100.70.0.5, 127.0.0.1, 192.168.0.10]
  public_hosts: [192.168.0.10]
  admin_hosts: [127.0.0.1]
AddressScope
192.168.0.10public
100.70.0.5users
127.0.0.1admin

host must list addresses explicitly (no 0.0.0.0 or ::) when it is combined with these settings.

Note

The first-run setup page is part of the admin panel, so it is served on admin listeners.

By port: public_port and admin_port

With a single address, public_port and admin_port open extra ports for the public and admin scopes.

port then has the users scope:

beetstreamnext:
  port: 8080
  public_port: 8081
  admin_port: 8082

Every address in host listens on all three ports: point your reverse proxy’s public hostname at 8081, the Subsonic hostname at 8080, and the admin hostname at 8082.

See Traefik with Docker for a complete example.

Using hosts and ports together

Addresses in public_hosts and admin_hosts get their scope on port, and public_port and admin_port cover the remaining addresses of host.

For example, with this config:

beetstreamnext:
  host: [100.70.0.5, 192.168.0.10]
  public_hosts: [192.168.0.10]
  port: 8080
  public_port: 8081
Listens onScope
100.70.0.5:8080admin
100.70.0.5:8081public
192.168.0.10:8080public

Good to know

  • These settings need a restart. With a Unix socket or in debug mode there is a single admin listener and they are ignored.

  • Public listeners have their own thread pool (up to 8 threads), so heavy public traffic can’t starve the others.


See external_hostname in the configuration reference.

Radio & podcasts

Internet radios and Podcasts do not come from your Beets library, and are managed entirely by BeetstreamNext.

Internet radio

BeetstreamNext implements the Subsonic internet radio endpoints (getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation).

Each user has their own radio station list from their account page or any Subsonic client that supports them. No specific role is required.

Note

When upgrading from BeetstreamNext v2.0.7 (where stations were shared server-wide), existing stations are automatically copied to every existing user.

  • Station discovery: Set enable_radio_discovery to search Radio Browser, a community-maintained directory of internet radio streams.

  • Icons: BeetstreamNext tries to scrape icons from the station homepage’s <link rel="icon">/apple-touch-icon tags, or falls back to DuckDuckGo’s icon proxy.

Podcasts

BeetstreamNext implements the full Subsonic podcast feature set: subscribing to RSS feeds, browsing channels and episodes, and downloading episodes for offline playback in a client.

  • Subscriptions per-user: Each user with the podcastRole can manage their own subscriptions.

  • Channel discovery: Set enable_podcast_discovery and configure podcastindex_api_key/podcastindex_api_secret (a free account at podcastindex.org) to search for podcasts from the admin panel.

  • Storage and auto-download:

    • podcast_storage_dir: where downloaded episodes are stored. Leave empty to use the default location.

    • podcast_auto_download_count: how many of a channel’s most recent episodes are automatically downloaded when a new channel is added. Set it to 0 to disable auto-download and only fetch episodes on request (see Note below).

  • OPML Import/Export: Supports importing/exporting existing podcast subscriptions.

Note

Many clients do not expose any “Download” button for individual episodes.

Because of that, BeetstreamNext triggers a download automatically when it receives an episode streaming request: the audio is written to disk and forwarded to the client at the same time.


See the configuration reference and enable_radio_discovery for these settings.

Jukebox mode

Subsonic’s Jukebox mode lets playback happen directly on the server, with the Subsonic client app acting as the remote control instead of streaming the audio itself.

BeetstreamNext exposes (in addition to the server’s own hardware) two network backends: Sonos and Chromecast, allowing the audio to be played through a speaker on the server’s local network.

Backend choice

Set jukebox_backend to:

BackendPlays onExtra dependency
server_hardware (default)The server’s own audio output (via mpv)mpv binary on PATH
sonosA Sonos speaker on the local network, via SoCopip install beetstreamnext[sonos]
chromecastA Chromecast device on the local network, via pychromecastpip install beetstreamnext[chromecast]

Jukebox mode must be enabled with the server-wide jukebox_allowed setting, and allowed per-user via that user’s jukeboxRole (see Accounts, permissions & chat).

Selecting a device

For any of the three backends, you can set which device will play the audio with jukebox_hardware_device:

  • for server_hardware: an mpv --audio-device value (e.g. alsa/hw:0,0 on Linux, coreaudio/BuiltInSpeakerDevice on macOS, etc). Empty for the system default.

  • for sonos: the Sonos speaker’s IP address.

  • for chromecast: the Chromecast device’s UUID, or its IP address/hostname.

Note

The admin panel also allows to scan for what devices are available on the current selected backend.

Running in Docker

Both network device-discovery mechanisms (SSDP/UPnP for Sonos, mDNS/Zeroconf for Chromecast) rely on LAN multicast, which does not cross Docker’s default bridge network.

If you’re containerizing BeetstreamNext, you have a few options:

  • network_mode: host: the container shares the host’s network namespace, so multicast just works and “Discover devices” behaves like it would bare-metal.

    • Simplest option, but it’s Linux-only in practice (Docker Desktop on macOS/Windows runs containers inside a VM, so host networking doesn’t give the container real access to your LAN’s multicast traffic).
  • A macvlan/ipvlan network: the container has its own IP directly on the LAN, so it sits on the same layer as your Sonos/Chromecast devices and multicast works.

    • This needs a physical (usually wired) interface, as most Wi-Fi drivers/APs won’t allow the extra MAC addresses macvlan relies on. Also you’d need an extra macvlan shim interface on the host (to still reach BeetstreamNext’s web UI locally). Bit of a hassle, but should be possible.
  • Don’t use discovery: set jukebox_hardware_device directly in the Web UI or in config.yaml (the speaker’s IP for Sonos, the Chromecast’s UUID/IP/hostname).

The server_hardware backend needs a real audio output device passed into the container:

  • ALSA (jukebox_hardware_device like alsa/hw:0,0): pass the host’s sound card in, and give the container access to it:

    # ... in your docker-compose.yaml
        devices:
          - /dev/snd:/dev/snd
        group_add:
          - "29"   # The host's 'audio' group gid, you can double-check yours with `getent group audio`
    # ...
    
  • PulseAudio/PipeWire (jukebox_hardware_device like pulse): bind-mount the host user’s runtime socket, and point mpv at it:

    # ... in your docker-compose.yaml
        volumes:
          - /run/user/1000/pulse:/run/pulse:ro   # Replace 1000 by the uid of the user who owns that socket on the host
        environment:
          PULSE_SERVER: unix:/run/pulse/native
    # ...
    

Beets import

BeetstreamNext can run beet import for you from the Web UI.

SourceInteractiveStarted fromBeets flags
Manually selected foldersYesWeb UInone (beets asks you what to do)
Pinned foldersNoWeb UI, Subsonic client-q, and --incremental or --noincremental
Watched foldersNoAutomatically on new files-q --incremental

Imports use your Beets config and library database (i.e. the same one that BeetstreamNext serves), so it includes all your preferred import settings, other plugins, etc.

Tip

I recommend using my beets-cataloghint plugin for better MusicBrainz matches :)

Manually selected folders

Enter a directory in the Beets tab and start the import. You get a terminal-like view of the session, and when Beets asks a question (apply, skip, pick another candidate, etc) you can type your answer, exactly like on the command line.

This is, to some extent, similar to what Betanin does.

Pinned folders

You can pin folders you regularly import from (a downloads directory for instance) so they can be imported in one click.

More importantly, importing from your pinned folders can be triggered from a Subsonic client using the startScan endpoint.

This is non-interactive: it runs in quiet mode (-q) and Beets never waits for input. It follows your config for quiet_fallback, timid, and so on.

Pinned folders also have an incremental toggle that controls Beets’ --incremental / --noincremental modes.

Note

Beets imports recursively, so a pinned folder can’t overlap your music library directory (be inside it or contain it) if your Beets config copies, moves or writes files (because the import would then modify the formatted library).

If write, copy and move are all disabled in your Beets config’s import section (an in-place library, where the music folder is new downloads arrive, and files are never modified), overlapping is allowed.

Watched folders (auto-import)

A pinned folder marked as watched is monitored ,and whenever new audio files are added to it, they are automatically imported.

  • This needs the import_watch_enabled setting to be on

  • Folders are polled every 30 seconds

  • A folder is only imported once its contents have stopped changing for import_watch_settle seconds (60 by default) (this is to avoid importing half-copied or half-downloaded albums)

  • If another import is running, the folder is queued

  • Watched imports are always quiet and incremental

Warning

Avoid triggering imports from a download client inside a watched folder (or a subfolder of one). It’s safer to use one or the other.

Triggering an import

An import can be triggered by a download client (qBittorrent, Deluge, Transmission, etc) when a download finishes.

  1. Set control_api_token (or BSN_CONTROL_API_TOKEN) to a long random value (e.g. use openssl rand -hex 24).

  2. Call POST /control-api/trigger-import when a download completes (see examples below).

FieldDescription
pathAbsolute path of the folder to import (as seen by BeetstreamNext)
interactiveOptional, false by default (see interactive)

The token can be sent as the X-Api-Key header value, Authorization: Bearer <token> or a token form field. Wrong tokens count towards the rate limiter.

  • By default the import follows the same rules as pinned folders. If another import is running, the request is queued.

  • With interactive=true, Beets waits for you in the Beets tab when it needs a decision (and sends an Import needs input notification). Other imports (queued and watched folders) wait behind it until you answer.

Note

The control API is only served on admin scope listeners by default. You can expose it on the user-facing interface by disabling restrict_control_api.

Warning

Don’t trigger imports inside a watched folder (or a subfolder of one). It’s safer to use one or the other.

Client scripts examples

The download clients run the script for every finished torrent, so the scripts below only keep music (the music label, category, or folder).

Transmission: set script-torrent-done-enabled and script-torrent-done-filename in settings.json and use this script.

#!/bin/sh
echo "$TR_TORRENT_LABELS" | grep -qw music || exit 0    # Only the "music" label

curl -fsS -X POST "https://bsn.example.com/control-api/trigger-import" \
  -H "X-Api-Key: <your_token>" \
  --data-urlencode "path=$TR_TORRENT_DIR/$TR_TORRENT_NAME"

Deluge: enable the Execute plugin and add this script for Torrent Complete event.

Deluge passes the torrent ID, name and directory as $1, $2, $3.

#!/bin/sh
case "$3" in /downloads/music*) ;; *) exit 0 ;; esac    # Only what arrives in /downloads/music

curl -fsS -X POST "https://bsn.example.com/control-api/trigger-import" \
  -H "X-Api-Key: <your_token>" \
  --data-urlencode "path=$3/$2"

qBittorrent: call /path/to/torrent-finished.sh "%L" "%F" in Run external program on torrent finished.

qBittorrent passes the category and content path as %L, %F.

#!/bin/sh
[ "$1" = "music" ] || exit 0    # Only the "music" category

curl -fsS -X POST "https://bsn.example.com/control-api/trigger-import" \
  -H "X-Api-Key: <your_token>" \
  --data-urlencode "path=$2"

Important

The torrent path must be the path as BeetstreamNext sees. If your download client sees that folder under a different path, rewrite the prefix in the script. For example:

#!/bin/sh
CLIENT_DIR="/downloads"         # As seen by the download client
BSN_DIR="/media/imports"        # As seen by BeetstreamNext

path="$BSN_DIR${1#"$CLIENT_DIR"}"   # This replaces "/downloads" with "/media/imports"

curl -fsS -X POST "https://bsn.example.com/control-api/trigger-import" \
  -H "X-Api-Key: <your_token>" \
  --data-urlencode "path=$path"

Skipped folders and retries

Beets’ incremental import remembers the folders it has seen (to avoid retrying them). By default, that includes folders that were skipped (manually or automatically).

Beets’ option incremental_skip_later turns that off, and makes every incremental run retry all the folders that were skipped.

BeetstreamNext does not depend on that option:

  • Watched folders, pinned scans and startScan always remember the folders they skip, so an album that couldn’t be matched is not retried over and over.

  • Manual imports and Reimports (see below) always retry the folder they correspond to, even if it was imported or skipped before. Note that when a folder contains several already-imported albums, Beets will ask about each of them (as it would do for any non-incremental import).

  • The import history allows you to retry skipped albums (as an interactive import) so you can choose the match yourself.

Important

When Beets merges a disc into an album already in the library (merge duplicate action), it saves the result under a weird list of folders and files. Single disc folders are not remembered.

The helper plugin records each folder separately so the watcher doesn’t look at them again.

Using other Beets import plugins

BeetstreamNext runs beet import with your configured plugins, so import plugins like beets-cataloghint and beets-unskipper can be used.

A few things to know regarding these two plugins:

  • When cataloghint is what triggered a skip, the import history and notifications will show the explanation it gives.

  • cataloghint tracks which release was picked for a multi-disc album, but only during one import run. A disc that arrives in a later watched run (or a later reimport) is matched on its own.

  • Imports started from inside unskipper are independent from BeetstreamNext: they don’t appear in the import history, send no notifications, and may conflict with a running watched import.

  • unskipper keeps its own record next to the state file. Albums that BeetstreamNext retried or recorded are detected by unskipper at its next start.

Spotify / Deezer import matching

Let’s say it once: MusicBrainz is the recommended autotagger source.

With the spotify or deezer plugins (if their priority is above musicbrainz’s in your beets config), Beets records the Spotify/Deezer IDs instead of the MusicBrainz ID, which complicates a lot of things.

BeetstreamNext does its best to cope with that. If you ever decide to retag an album to replace Spotify/Deezer IDs with MusicBrainz IDs, BeetstreamNext will update everything it stores for the old ID (likes, ratings, play counts, bookmarks, play queues, shares) to use the new one.

Important

Your clients can still have the old IDs in cache, so force a refresh or resync in the client. Any local data (offline downloads, local play counts, etc) will likely need to be regenerated.

Warning

Going the other way (replacing MusicBrainz tags with Spotify/Deezer) is not supported. Beets overwrites the MusicBrainz ID gets and it can’t be recovered, so all IDs that were communicated to clients stop resolving.

Moving your library

beet unskipper --remap OLD=NEW --migrate-db only updates what Beets and unskipper store. So if you are moving your music, also remember to update BeetstreamNext:

  • Pinned folders (remove and add them again with the new path)
  • The import history: entries will still point to the old path, so their reimport button will fail. Use “Clear history” and let new imports repopulate it
  • File-based playlists (Beets playlist/smartplaylist files) that contain absolute paths

Stop BeetstreamNext while --migrate-db runs, since it rewrites the Beets database.

Editing the Beets config

The Beets tab shows your Beets config.yaml.

Editing it from the Admin WebUI is restricted by default for security. You can allow this by enabling the allow_beets_config_edit setting or by setting its corresponding environment variable.

Warning

A Beets config can load plugins from any path and run shell commands when an import runs. Whoever can edit it can therefore potentially run code on the server (as the user beets runs as).

Only enable it if you trust every admin account and the network they reach the panel from (i.e. via HTTPS, and ideally restricting the panel to a private listener with admin_hosts or admin_port).

Blocked settings

Even with editing enabled, the config editor refuses to save any change that adds or modifies a setting that’s able to run code or commands.

KindBlocked
Pluginsinline and hook (can’t be added to plugins)
Top-level keyspluginpath, item_fields, album_fields
Command/binary optionshook.hooks, convert.command, convert.formats, play.command, play.raw, badfiles.commands, replaygain.command, keyfinder.bin, absubmit.extractor
  • The YAML is parsed with a safe loader so Python-specific tags are never evaluated
  • Entries that were already in the file are trusted as long as you leave them unchanged, so a config that legitimately uses convert.command can still be edited around it. Changing or adding one is refused, and the response lists the offending names

Important

This is not a sandbox: it can’t stop someone with access to the file on disk, and it only knows about the options listed above. Third-party plugins that can execute commands are not covered.

Disk writes

Imports that write tags, or that copy/move files, need allow_disk_writes enabled. This isn’t required if your Beets config has write, copy and move all disabled.

This also counts some plugins as disk-writing when they are loaded:

PluginConsidered unsafe when
scrubscrub.auto is on
embedartembedart.auto is on
fetchartfetchart.auto is on
hookalways

If the config can’t be resolved, the import is treated as unsafe and requires allow_disk_writes.

Notifications

BeetstreamNext can send an Apprise notification when an import starts, finishes, fails or needs input.

Notifications

BeetstreamNext can send a notification when something happens (a Beets import, a blocked login, a finished health scan, a new share, a missing music folder…), through Apprise.

Apprise supports most chat, push and email services (Discord, Telegram, Slack, Matrix, ntfy, Pushover, Gotify, email, generic webhooks, and many more).

Installation

Apprise is an optional extra:

pip install beetstreamnext[notifications]

It is included in beetstreamnext[all] and in the default Docker image. Without it, the admin Notifications tab only shows an install hint, and nothing is ever sent.

Notifications

A notification is a message with:

FieldDescription
TypeWhat triggers it (see below)
TitleThe message title, can use variables
BodyThe message body, can use variables
URLsOne or more Apprise URLs, e.g. discord://webhook_id/token. The notification is sent to every URL in the list.

You can create as many notifications as you like, including several of the same type (for instance to word the message differently on different services).

In the config file

Notifications can also be declared in the config file:

notifications:
  - event: import_completed
    title: "Imported {albums_imported} album(s)"  # optional
    body: "{path} took {duration}"                # optional
    urls:                                         # or a single `url:`
      - discord://webhook_id/token
    options:
      skip_if_unchanged: false
    enabled: true                                 # optional, overridable from the UI
  • event can be any type (see just below): import_started, import_completed, import_failed, import_needs_input, import_queue_aborted, login_blocked, health_scan_completed, health_scan_failed, maintenance_completed, maintenance_failed, share_created, share_accessed, storage_problem, server_started

These are shown in the admin panel as read-only.

Types

TypeSent when
Import startedA beet import run starts (from the admin panel, a pinned scan, a Subsonic client’s startScan, or a watched folder)
Import completedAn import finishes with exit code 0
Import failedAn import finishes with a non-zero exit code
Import needs inputAn interactive import is waiting for an answer (once each time it starts waiting)
Pinned scan abortedA queued pinned folder scan couldn’t be started, and the rest of the queue was dropped
Login blockedToo many failed logins locked out a user / IP
Health scan completedA library health scan finishes (can be limited to scans where songs are flagged)
Health scan failedA library health scan couldn’t run, or crashed
Maintenance task completedA maintenance action from the admin panel (clear caches, database cleanup, offline files cleanup) succeeded
Maintenance task failedA maintenance action from the admin panel failed
Share createdA user creates a share (from a Subsonic client)
Share accessedA share’s public page is opened
Storage problemLibrary files can’t be found on disk (checked on startup and after each health scan)
Server startedBeetstreamNext finished starting up (also after a restart)

Note

A quiet watched-folder import exits with 0 even when Beets skipped albums, so Import completed doesn’t mean everything was imported.

Variables

Notification titles and bodies can contain {variables}, replaced when the notification is sent:

VariableValue
{path}The folder being imported
{status}started, completed, failed, needs input, aborted, blocked or running
{exit_code}The exit code of the import (empty until it has finished)
{duration}How long the import ran for, e.g. 1m 23s (empty until it has finished)
{albums_imported}How many albums were added to the library (Import completed / Import failed)
{albums_skipped}How many albums were skipped: no match, duplicates, or skipped by you
{imported_list}Names of the imported albums (Artist - Album), one per line (first 5 only)
{skipped_list}Names of the skipped albums, one per line (first 5 only)
{ip}The IP the failed logins came from (Login blocked)
{username}The username that was tried, empty when the whole IP was blocked (Login blocked)
{failures}The number of failed attempts that triggered the block (Login blocked)
{songs_checked}Songs checked by the scan (Health scan completed)
{songs_flagged}Songs currently flagged in the whole library (Health scan completed)
{songs_unchanged}Songs skipped because they hadn’t changed since the last scan
{time}When the notification was sent

Album results

BeetstreamNext loads a small helper plugin (beetstreamnext_events) in the beet import process, which reports each album’s outcome

  • Only albums are counted, not singletons (beet import -s).

  • This only covers imports run by BeetstreamNext, not the ones you run from your commandline or other tools.

  • Skipped albums are listed with a reason: skipped by you, no match found, best match not confident enough, or already in the library.

  • If you use my cataloghint plugin, its explanation is used when it is what made the match unconfident.

Note

This companion plugin is automatically injected in the beets import process started by BeetstreamNext, no change are made to your beets config.

Remarks

  • URLs often contain tokens or passwords: they are stored encrypted in BeetstreamNext’s database, with the BEETSTREAMNEXT_KEY that protects other secrets (see Installation). They are shown in the admin panel though (for now).
  • Notifications are currently only managed through the admin panel, they can’t be set via configuration setting or YAML block (yet).

Installation

BeetstreamNext can run in two modes:

  • Plugin mode: Attaches to an existing Beets install and reuses its config.yaml.

  • Standalone mode: Runs in its own process, pointed directly at a library.db (still beets-managed, just not invoked as a Beets plugin). Useful when Beets lives in a different container or host than BeetstreamNext.

Both existing modes share the same feature set and settings, only how you configure and launch the server differ slightly.

Requirements

  • Python 3.13+

  • Beets installed

    • Plugin mode needs a working Beets install with a library you already import music into.

    • Standalone mode needs beets available as a Python package (for its query engine and file-path handling).

  • ffmpeg installed and available on PATH

  • Optionally, mpv installed and available on PATH for jukebox mode using the server’s own audio hardware (not needed for the sonos/chromecast jukebox backends, or if you don’t use jukebox mode. See Jukebox info).

Note

Both ffmpeg and mpv binaries can be pointed to explicitly if they aren’t on your PATH, with the BSN_FFMPEG_PATH/BSN_MPV_PATH environment variables or the ffmpeg_path/mpv_path keys of the config file.

Note

These requirements are for running from source. If you run BeetstreamNext via the Docker image instead, everything is already bundled with it.

Running from source

1. Clone and install:

git clone https://github.com/FlorentLM/BeetstreamNext.git
pip install BeetstreamNext

or if you installed Beets via the recommended uv tool command:

git clone https://github.com/FlorentLM/BeetstreamNext.git
uv tool install beets --with ./BeetstreamNext --reinstall

Optional extras add the dependencies for specific features:

pip install .[wiki]              # Wikipedia artist-biographies (using wikipedia-api)
pip install .[podcasts]          # Podcast support (using feedparser for the RSS feeds)
pip install .[podcast-discovery] # Podcast channel discovery (using the Podcast Index API)
pip install .[radio-discovery]   # Internet radio station discovery (using the Radio Browser API)
pip install .[sonos]             # Sonos speaker jukebox backend (using SoCo)
pip install .[chromecast]        # Chromecast jukebox backend (using pychromecast)
pip install .[notifications]     # Notifications for imports (using Apprise)

or

pip install .[all]               # Installs all optional dependencies

(With uv, use for instance uv sync --extra podcasts, or uv sync --extra all)

This installs a beetstreamnext console command (used by standalone mode) alongside the beetstreamnext Beets plugin (used by plugin mode).

2. Configure and run

Plugin mode

Add beetstreamnext to the plugins line in Beets’ config.yaml, and put any BeetstreamNext settings under a beetstreamnext: block in that file:

plugins: beetstreamnext

beetstreamnext:
  port: 8080

See the configuration reference for the full list of available settings.

Then just run it through beet’s own subcommand:

beet beetstreamnext

Plugin-mode flags:

--create-user, --update-user USERNAME, --delete-user USERNAME, --password USERNAME, --list-users, --clear-cache, --backup FILE, --restore FILE, plus --host/--port/--threads/--debug to override those settings for a single run.

See CLI usage for the full command reference.

Standalone mode

Standalone mode resolves its Beets library path (library-db) and music root (music-root) following a specific order:

  • library_db:
flowchart LR
    A("<code>--library-db</code> CLI flag") --> B("<code>BEETS_LIBRARY_DB</code> env var") --> C("<code>library_db</code> in YAML config") --> D("beets.config['library']<br/><br/>(only if <code>beets_config_path</code> was resolved)") --> E("Error")

style A fill:#ffffff12,stroke:#808080
style B fill:#ffffff12,stroke:#808080
style C fill:#ffffff12,stroke:#808080
style D fill:#ffffff12,stroke:#808080
style E fill:#f8d7da44,stroke:#dc3545,stroke-width:3px
  • music_root:
flowchart LR
    A("<code>--music-root</code> CLI flag") --> B("<code>MUSIC_ROOT</code> env var") --> C("<code>music_root</code> in YAML config") --> D("beets.config['library']<br/><br/>(only if <code>beets_config_path</code> was resolved)") --> E("WebUI setting") --> F("Error")

style A fill:#ffffff12,stroke:#808080
style B fill:#ffffff12,stroke:#808080
style C fill:#ffffff12,stroke:#808080
style D fill:#ffffff12,stroke:#808080
style E fill:#ffffff12,stroke:#808080
style F fill:#f8d7da44,stroke:#dc3545,stroke-width:3px

Every other setting follows the (roughly similar) order defined in Configuration, and can also be set from the Admin panel (except library_path, see Note below).

Note

Since --library-db/BEETS_LIBRARY_DB/library_db (in the YAML) is required at every run just to locate BeetstreamNext’s database, there’s no scenario where it isn’t explicitly set, so it is currently never editable in the WebUI (I might revise this).

At minimum, you should point BeetstreamNext to your library.db and music root:

beetstreamnext run --library-db /path/to/library.db --music-root /path/to/music

Or via a YAML config file, pass --config, or just put your configuration file at the default location for your platform:

  • Docker: /config/beetstreamnext.yaml
  • Linux/macOS: $XDG_CONFIG_HOME/beetstreamnext/beetstreamnext.yaml (usually ~/.config/beetstreamnext/beetstreamnext.yaml)
  • Windows: %APPDATA%\beetstreamnext\beetstreamnext.yaml
# beetstreamnext.yaml
library_db: /path/to/library.db
music_root: /path/to/music
port: 8080
beetstreamnext run --config /path/to/beetstreamnext.yaml

You can also use environment variables (handy for containers):

export BEETS_LIBRARY_DB=/path/to/library.db
export MUSIC_ROOT=/path/to/music
beetstreamnext run

Standalone subcommands:

create-user, update-user USERNAME, delete-user USERNAME, passwd USERNAME, list-users, clear-cache, backup FILE, restore FILE

See CLI usage for the full flag list (--bsn-db, --beets-config, --host, --port, --threads, --debug, --force-trust-host).

Important

If a Beets config file passed via --beets-config contains a beetstreamnext: section, it will be ignored in standalone mode.

Put these settings in a dedicated BeetstreamNext --config YAML file, or use BSN_* environment variables, or set them via the Admin WebUI.

Run via Docker

A prebuilt image is published to GitHub Container Registry (GHCR):

ghcr.io/florentlm/beetstreamnext:latest (or use a specific version tag if you wish, e.g. :2.0.0).

Minimal example command:

docker run -d --name beetstreamnext \
  --restart unless-stopped \
  -p 8080:8080 \
  -e PUID=1000 -e PGID=1000 \
  -v /path/to/config:/config \
  -v /path/to/library.db:/data/library.db:ro \
  -v /path/to/music:/music:ro \
  -e BEETS_LIBRARY_DB=/data/library.db \
  -e MUSIC_ROOT=/music \
  ghcr.io/florentlm/beetstreamnext:latest

If you need something the default image doesn’t give you (a non-default feature-set extra, a specific pinned beets version, debug tools, etc), you should build it yourself instead using the Dockerfile available in the docker folder of the repository.

Example:

docker build -f docker/Dockerfile -t beetstreamnext --build-arg BEETS_VERSION=2.11.0 .

Build-time options (--build-arg):

  • EXTRAS (default all): which optional feature sets to install, comma-separated. Same list as above: wiki, podcasts, podcast-discovery, radio-discovery, sonos, chromecast, notifications, or all.

  • PYTHON_VERSION (default 3.13): Python version to use instead of the one defined in pyproject.toml.

  • BEETS_VERSION: beets to use instead of the one defined in pyproject.toml.

  • WITH_MPV (default true): install mpv (needed for the server_hardware jukebox backend, see Jukebox mode). You can set to false if you don’t use that backend and want a smaller image.

  • WITH_DEBUG_TOOLS (default false): also install curl, wget, ping, dig/nslookup, nc, and ip/ss, for poking at networking issues from inside the container (e.g. docker exec -it beetstreamnext curl ...).

Image paths

  • /config contains:

    • the beetstreamnext.yaml configuration file
    • the .env file (holding BEETSTREAMNEXT_KEY, see Encryption key)
    • the BeetstreamNext database (beetstreamnext.db)
    • a data/ subfolder: contains downloaded podcast episodes, and saved artist images
  • /cache is scratch space, with two subfolders:

    • data: thumbnails, HTTP cache, session key
    • tmp: transcode tempfiles, HLS sessions, zip downloads, SQLite/Python tempfiles

Tip

The data cache subfolder is worth bind-mounting to keep the data across containers restarts.

The tmp subfolder is fine to discard, but you can bind-mount it if you want the cache to survive restarts.

Mounting them separately is useful if you want different backing storage for each (see hardening the container below).

Other image options

PUID/PGID (default 1000/1000): these should match the user that owns your library/music files on the host.

Important

BeetstreamNext never runs as root. The root user is only used at container start, to chown /config and /cache to PUID/PGID before dropping to it for the rest of the process’s life.

Tip

If you want to control the folders’ ownership yourself, you can also run the container as a specific user directly (use docker run --user UID:GID, in which case also make sure /config is already owned by that user), and the entrypoint will notice and won’t try to switch users itself.

Adding beets plugins

Third-party beets plugins can be added without rebuilding the image. Set BSN_BEETS_PLUGINS to a whitespace-separated list of pip-friendly entries (PyPI packages names or git repositories):

-e BSN_BEETS_PLUGINS="beets-cataloghint git+https://github.com/FlorentLM/beets-unskipper.git git+https://github.com/someone/beets-otherplugin.git@v1.2.0"

Or in docker-compose:

environment:
  BSN_BEETS_PLUGINS: >-
    beets-cataloghint
    git+https://github.com/FlorentLM/beets-unskipper.git

Tip

Check em out! → beets-cataloghint and beets-unskipper ← 😃

Then enable the plugin in your beets config (/config/beets/config.yaml) as usual, and restart the container:

plugins: follow cataloghint unskipper

At startup, the container installs the list into /config/plugins/installed (so they persist across container re-creation). It only reinstalls if the list changes, and it removes the plugins removed from the list.

You can also use /config/plugins/requirements.txt file to list your wanted plugins. If present, its content is merged with BSN_BEETS_PLUGINS.

Important

Custom plugins can’t replace packages shipped with the image (beets itself, or BeetstreamNext’s dependencies). A plugin requiring an incompatible version fails to install, and the container starts without the change.

Important

Plugins with system-level dependencies (a command-line tool, a C library) can’t be installed this way. The image only includes git, ffmpeg (and mpv, unless disabled).

Use a custom image FROM the published one if you need that.

Run via docker-compose

There’s a docker-compose.yml in the docker folder of the repository, equivalent to the docker run command above (BeetstreamNext only, pulling the published image by default).

Edit the two host paths in it, then:

docker compose up -d

To build locally instead, comment out the image: line and uncomment the build: block.

Example stack: BeetstreamNext + Betanin

A small stack pairing BeetstreamNext with Betanin, a web UI that drives beet import. In this example, Betanin owns the beets config and library.db, BeetstreamNext only ever reads them.

The two containers need to share:

  • The beets home directory (config.yaml + library.db): needs to be read-write for Betanin, but can be read-only for BeetstreamNext
  • The music directory: can be read-only for both

Caution

Concurrent SQLite access to library.db is only reliable on a real shared filesystem or bind-mount (same host, sharing a named volume).

If Betanin and BeetstreamNext are on different hosts, do not mount library.db over NFS/SMB, SQLite’s file locking isn’t reliable over most network filesystem protocols.

This example assumes it is saved as docker-compose.yml inside the docker folder of a BeetstreamNext checkout (the build: section needs the Dockerfile there).

services:
  betanin:
    image: sentriz/betanin
    container_name: betanin
    restart: unless-stopped
    ports:
      - "9393:9393"
    environment:
      UID: "1000"
      GID: "1000"
    volumes:
      - betanin-data:/b/.local/share/betanin
      - betanin-config:/b/.config/betanin
      - beets-home:/b/.config/beets   # shared with BeetstreamNext
      - /path/to/music:/music
      - /path/to/downloads:/downloads

  beetstreamnext:
    # You need to build the image locally if you want to pin BEETS_VERSION to match Betanin (see note below)
    build:
      context: ..
      dockerfile: docker/Dockerfile    # required if you build from another location (the Dockerfile isn't at the repo root)
      args:
        BEETS_VERSION: 2.11.0    # Betanin currently uses beets 2.11.0
    container_name: beetstreamnext
    restart: unless-stopped
    depends_on:
      - betanin
    ports:
      - "8080:8080"
    environment:
      PUID: "1000"
      PGID: "1000"
      BEETS_LIBRARY_DB: /beets/library.db
      MUSIC_ROOT: /music
    volumes:
      - beetstreamnext-config:/config
      - beets-home:/beets:ro          # same volume as Betanin's /b/.config/beets (read-only here)
      - /path/to/music:/music:ro      # same host path as Betanin's /music (read-only here)

volumes:
  betanin-data:
  betanin-config:
  beets-home:
  beetstreamnext-config:

Tip

You can run docker exec betanin beet version (or whatever your Betanin container is called) to see which version of beets it is using.

Edit the two /path/to/... host paths, then docker compose up -d.

Open Betanin at :9393 to configure/run imports, and BeetstreamNext at :8080.

Note

Because the music folder and the library are mounted :ro for BeetstreamNext in this example, the admin panel will show a red “read-only” pill next to any setting that would otherwise write to them (see Configuration reference).

Remapping paths

Beets stores track paths in library.db as a relative path to the directory value in its YAML config file, exactly as they were seen by the beet import/beet update process.

In the example above, beet is in the betanin container, but it could just as well be on a different machine entirely. The compose file example above avoids any issue by mounting the one music directory (on the host) at an identical path in both containers (/music).

If you can’t (or don’t want to) use the same path in both, you can use music_root with library_remote_path:

  • music_root: where BeetstreamNext sees mounts the music volume

  • library_remote_path: where the container/machine that owns the library sees that same folder

BeetstreamNext will then substitute one prefix for the other on every path it reads from the library.

So, for instance, a /downloads/music/Artist/Album/01.flac path in library.db would resolve to /music/Artist/Album/01.flac in BeetstreamNext’s container.

Example:

services:
  betanin:
    volumes:
      - /some/path/to/your/music:/downloads/music   # Betanin's convention
      # ...

  beetstreamnext:
    environment:
      MUSIC_ROOT: /music
      BSN_LIBRARY_REMOTE_PATH: /downloads/music
      # ...
    volumes:
      - /some/path/to/your/music:/music:ro   # Same host path, different mount point
      # ...

Adding an Nginx sidecar for offloading file serving

To offload file serving with an an Nginx sidecar, remove ports: - "8080:8080" from the beetstreamnext service (Nginx is the one that publishes to the host), then add:

  nginx:
    image: nginx:1.27-alpine
    container_name: nginx
    restart: unless-stopped
    depends_on:
      - beetstreamnext
    ports:
      - "8080:8080"
    volumes:
      - /path/to/nginx.conf:/etc/nginx/nginx.conf:ro
      - /path/to/music:/music:ro   # Same host path as beetstreamnext's /music above

BeetstreamNext also needs reverse_proxy: true and sendfile_method: x-accel-redirect.

See the Full working example: Nginx sidecar for a Docker deployment for the matching nginx.conf.

Other standalone subcommands work by overriding the container’s command. For example, an unattended first run:

docker run --rm \
  -e BSN_ADMIN_USER=admin -e BSN_ADMIN_PASSWORD=Hunter2?? \
  -e BEETS_LIBRARY_DB=/data/library.db \
  -v /path/to/config:/config \
  -v /path/to/library.db:/data/library.db:ro \
  beetstreamnext create-user --noinput

Important

Here -v /path/to/config:/config must point at the same host path as what you’ll use in the main run command (the encryption key and the user this creates both get stored under /config, so the two runs need to share it to see the same user/key).

Tip

When using Docker, you probably want to use Docker secrets. You can add the BSN_NO_KEY_FILE=1 to that command to prevent it from writing the .env file (see unattended first run).

Advanced: hardening the container

There are some compose/docker run hardening options people like to enable.

  • read_only: true

Makes the whole container filesystem read-only except explicitly mounted volumes. /config is already a normal writable volume mount, but if you’re not mounting /cache, you need to give it a tmpfs mount:

read_only: true
tmpfs:
  - /cache:mode=1777

Caution

A tmpfs mount lives in RAM and is counted against the container’s memory limit (deploy.resources.limits.memory), and can’t be reclaimed under memory pressure.

If you have a big library, a full scan can make SQLite write sizeable temp files into /cache, which may trigger SQLite disk I/O errors.

Either give the tmpfs an explicit size (/cache:mode=1777,size=256m) and raise the container’s memory limit, or mount /cache as a normal volume instead.

Since /cache is split into /cache/data and /cache/tmp, you can also mount just the tmp half as tmpfs and leave data as a normal volume, so the cache survives restarts but the write-heavy half still gets RAM speed:

volumes:
  - /path/to/cache/data:/cache/data
read_only: true
tmpfs:
  - /cache/tmp:mode=1777,size=256m
  • cap_drop: [ALL]

The container needs none of the default capabilities so you can drop them all.

cap_drop:
  - ALL

The entrypoint with the default user needs some of them briefly though, so you need to add:

cap_add:
  - CHOWN         # To chown /config and /cache to PUID:PGID
  - SETUID        # To switch from root to PUID
  - SETGID        # To switch from root to PGID
  - DAC_OVERRIDE  # Needed for the above two to work on files that are not already owned by PUID/PGID

…unless you use a custom user:

  • user: PUID:GID

If you’re using a custom user, the entrypoint doesn’t need any special capabilities so you can just do:

user: "1000:1000"
cap_drop:
  - ALL

Important

The /config and /cache mounts need to already be owned by that same UID/GID on the host before the container starts.

  • security_opt: [no-new-privileges:true]

Stops any process in the container from gaining privileges beyond what it started with. It doesn’t interact with anything else BeetstreamNext does, so there’s no reason not to set it regardless of which other options above you use.

security_opt:
  - no-new-privileges:true
  • deploy.resources.limits

memory: Caps the container’s total memory. If you’re using read_only + tmpfs above: a tmpfs mount counts against this limit and can’t be reclaimed under memory pressure, so size it comfortably above your tmpfs size plus BeetstreamNext’s normal usage.

deploy:
  resources:
    limits:
      memory: 512m   # or even 1014 if your library is large
      pids: 100

pids: Caps the number of processes/threads the container can create, as defense against a fork bomb or some runaway process spawning from a bug or a compromised dependency.

pids_limit: 100

Encryption key

User passwords are stored as one-way hashes (scrypt).

The server key BEETSTREAMNEXT_KEY is the database encryption key for everything else: sensitive settings (such as third-party API keys) and the secrets of app passwords created with Usable as a password (Subsonic’s legacy MD5-token auth needs to recompute md5(secret + salt) on every login).

This is meant to protect against the database file leaking on its own: a backup that includes library.db/beetstreamnext.db but not the dotfiles, a misconfigured endpoint serving the db file, a db copied to a new host without also copying its key …this sort of thing.

Given that, how the server key is provisioned matters:

  • You have secrets management (Docker secrets, systemd LoadCredential, Vault, a k8s Secret, etc): Set BEETSTREAMNEXT_KEY as an environment variable before the first run. BeetstreamNext detects it and uses it. It’s never written to disk.

  • You don’t have secrets management: On first run, BeetstreamNext generates a new key, prints it once, and saves it to a .env file next to your database. Keep that file safe: set restrictive permissions if your platform doesn’t already (it’s created with 0600), and make sure it’s excluded from anywhere the database itself isn’t equally protected.

Note

If BEETSTREAMNEXT_KEY is set but isn’t valid, BeetstreamNext will refuse to start.

If the key is lost, the stored secrets become unrecoverable and you will need to delete the BeetstreamNext database and set up again (of course your Beets library stays perfectly fine).

Important

You also need this server key during the first-run admin account creation if using the Web UI (see below).

First run

The first time the server starts with no users in the database, it walks you through creating the initial admin account:

  • Running interactively in a terminal: you’re prompted right there for a username and password, and the account is created automatically as an admin.

  • Running non-interactively (a service manager, a container, anything without a TTY attached): Accessing the Web UI directs you to a setup wizard. That page will ask for the BEETSTREAMNEXT_KEY from the step above (this is to prevent letting anyone else who accesses the web UI from setting up the account before you do).

  • Fully unattended (for instance a Docker container with no TTY and nobody to click through a setup page): before starting the server, run once create-user --noinput (with BSN_ADMIN_USER/BSN_ADMIN_PASSWORD set). See below.

Creating a user doesn’t create any credential for Subsonic clients. Once signed in, create an app password.

Once at least one user exists, use --create-user/create-user (or the admin panel’s Users tab) to create other user accounts.

Unattended first run

You can setup the first admin account in a completely unattended way by using --noinput with the create-user command.

This reads the BSN_ADMIN_USER/BSN_ADMIN_PASSWORD env vars, creates that admin account, and exits immediately (it doesn’t start the server).

You can also add BSN_NO_KEY_FILE=1 to prevent it from writing the .env file containing the BEETSTREAMNEXT_KEY server key.

Example:

BSN_ADMIN_USER=admin BSN_ADMIN_PASSWORD=Hunter2?? beet beetstreamnext --create-user --noinput

(obviously replace admin and Hunter2?? by your chosen admin username and password)

Note

This is a one-time step. It will refuse to run if any user account already exists.

Startup

By default the server listens on 0.0.0.0:8080. Open http://<host>:8080 to see the public homepage, where you can log into the admin dashboard (see Web UI usage).

Point any Subsonic/OpenSubsonic/Navidrome client (see tested clients) at that same address. You can use the admin account directly, or a non-admin user account you create afterwards if you wish.


See the configuration reference for any of the settings mentioned here.

CLI

BeetstreamNext can be started via two different entrypoints (via beet beetstreamnext in plugin mode, or via beetstreamnext in standalone mode, see Installation).

However, the command-line surface underneath is the same in both modes (user management, cache clearing, server flags).

Plugin mode

In plugin mode, everything goes through Beets’ beet beetstreamnext subcommand:

beet beetstreamnext [options]
FlagEffect
(none)Start the server
--host HOST[,HOST...]Override host for this run (comma-separated for multiple)
--port PORTOverride port for this run
--threads NOverride how many threads the Waitress worker uses for this run
--debugRun in Flask debug mode
--force-trust-hostForce debug mode even when not bound to localhost (NOT RECOMMENDED)
-c, --create-userCreate a new user (interactive prompts)
--noinputWith --create-user: non-interactive, see Unattended first run
-u, --update-user USERNAMEUpdate an existing user’s roles (interactive prompts)
-d, --delete-user USERNAMEDelete a user (asks for confirmation)
-p, --password USERNAMEChange a user’s password (interactive prompts)
--list-usersList all registered users and their roles
--clear-cacheClear the thumbnail and HTTP caches
--backup FILEBack up settings and data to FILE (.tar.gz), or into a directory.
--restore FILE [--yes]Restore from a backup (the server must be stopped).
--rekey [--yes]Re-encrypt the database with a new BEETSTREAMNEXT_KEY (the server must be stopped).

Note

User-management or cache flags perform the action and exit immediately. They do not start the server.

Settings not overridden by a flag come from Beets’ config.yaml (under the beetstreamnext: key), see the configuration reference.

Standalone mode

beetstreamnext [command] [username] [options]
CommandEffect
runStart the server (default)
create-userCreate a new user (interactive prompts)
update-user [username]Update an existing user’s roles (interactive prompts)
delete-user [username]Delete a user (asks for confirmation)
passwd [username]Change a user’s password (interactive prompts)
list-usersList all registered users and their key roles
clear-cacheClear the thumbnail and HTTP caches
backup FILEBack up settings and data to FILE (.tar.gz), or into a directory
restore FILE [--yes]Restore from a backup (the server must be stopped)
rekey [--yes]Re-encrypt the database with a new BEETSTREAMNEXT_KEY (the server must be stopped)

Note

command defaults to run if omitted



FlagsEnv varEffect
--config PATH—BeetstreamNext YAML config file (default location is OS-dependent, see here)
--library-db PATHBEETS_LIBRARY_DBPath to the Beets library.db
--music-root PATHMUSIC_ROOTMusic root directory (where song paths are relative to)
--bsn-db PATHBSN_DB_PATHPath to BeetstreamNext’s database (default: alongside library.db, or /config in Docker)
--beets-config PATHBSN_BEETS_CONFIGOptional Beets config file, for path formats/plugins only (default: $BEETSDIR/config.yaml, if set and present)
--host HOST[,HOST...]BSN_HOSTHost(s) to listen on
--port PORTBSN_PORTPort to listen on
--threads NBSN_THREADSWaitress worker threads
--debug—Run in Flask debug mode
--force-trust-host—Force debug mode even when not bound to localhost (NOT RECOMMENDED)
--as-password—With create-app-password: also usable as a password by legacy clients
--noinput—With create-user: non-interactive, see Unattended first run

See the configuration reference for the full settings precedence rules, or Installation for more on library-db/music-root specifically.

Interactive prompts

create-user/--create-user, update-user/--update-user, passwd/--password, and delete-user/--delete-user all prompt interactively on the terminal:

  • Create: Asks for a username (flags invalid characters and offers a sanitized alternative), a password, and asks you if the new user should be an admin or not.

  • Update: Walks through every role one at a time (press Enter to leave a role unchanged, or y/n to enable/disable it).

  • Password change: Asks for the new password.

  • Delete: Asks for a confirmation before removing the account.

The interactivity is deliberate: these commands touch credentials, so user input is mandatory. Only exception to this is the Unattended first run.


Backup and restore

backup writes a snapshot of BeetstreamNext’s database (settings, users, app passwords, avatars, likes, ratings, bookmarks, playlists, shares, radio stations, podcasts, etc) to a .tar.gz.

beetstreamnext backup /backups/                      # -> /backups/beetstreamnext-backup-<timestamp>.tar.gz
beet beetstreamnext --backup /backups/bsn.tar.gz     # plugin mode

restore replaces the current database with the one in the archive. The server must not be running. The previous database is kept next to it as beetstreamnext.db.pre-restore-<timestamp>.

beetstreamnext restore /backups/bsn.tar.gz [--yes]
  • The archive contains password hashes: keep it private (it is created 0600).

  • BEETSTREAMNEXT_KEY is not included. Store it separately.

  • To restore a backup made with a different key, see Changing the key.

  • Your Beets library.db and music files are not part of the backup. Thumbnail/HTTP caches are not part of it either.

Changing the key

rekey allows you to update the database’s encryption key to a new one. The server must not be running.

A copy of the database from before the change is kept as beetstreamnext.db.pre-rekey-<timestamp>. That copy stays readable with the old key.

export BEETSTREAMNEXT_KEY=<new key>
export BEETSTREAMNEXT_OLD_KEY=<old key>     # or leave unset to be prompted
beetstreamnext rekey [--yes]
beet beetstreamnext --rekey [--yes]         # plugin mode

To restore a backup made with another key, set BEETSTREAMNEXT_OLD_KEY to the backup’s key when running restore. The backup is re-encrypted with the current BEETSTREAMNEXT_KEY before it replaces the database:

export BEETSTREAMNEXT_KEY=<key to use from now on>
export BEETSTREAMNEXT_OLD_KEY=<key the backup was made with>
beetstreamnext restore /backups/bsn.tar.gz

If BEETSTREAMNEXT_OLD_KEY is not set and you run restore from a terminal, you are prompted for it.

Web UI

The Web UI is mostly self-explanatory so this is just a quick tour, plus the handful of things that are only reachable through it (that is, not exposed to Subsonic clients).

Hover on the screenshot to see Light/Dark modes.

Public homepage

Your configured http://<host>:<port>/ (or your external_hostname) shows a public homepage.

If public_now_playing is enabled, a card displays the currently playing track (off by default).

Public homepage

Public shares

TODO

Admin dashboard

The dashboard is organized into tabs. Most settings are editable live, those that require a server restart to take effect are clearly marked.

A setting that is already pinned by a CLI flag, an environment variable, or a config.yaml value shows as locked/disabled from the Web UI.

See Configuration reference for the precedence rules.

Users

Create/update/delete users, toggle their roles, manage avatars, and see or revoke their app passwords.

Users

Connections

Your existing app passwords and signed-in web sessions.

Connections

Playlists

Browse and play playlists, and export them as M3U or CSV (see Moving a playlist to a streaming service).

Server

Every Server & Network related setting.

Server settings

Library

Every Library & Metadata related setting.

Library settings

Audio

Every Audio & Jukebox related setting.

Audio settings

Security

Live view of the current IP allow/deny lists and rate-limit state (see Security). Allows adding/removing entries, and clearing rate-limit buckets.

Security tab

Podcasts & radio

Add/refresh/delete podcast subscriptions and internet radio stations.

Radio discovery (when enable_radio_discovery is on) allows you to find new stations.

Radio stations

Podcasts

Artists

Browse, search, add or replace artist images, and fetch the missing ones from external services (see Library augmentation).

Artist images

Shares

View, edit and revoke any active public share.

Shares

Chat moderation

View the full chat log, edit/delete any user’s message for moderation, or post server announcements.

Chat moderation

Beets

Interact with the Beets installation: view Beets’ config.yaml (editing it needs to be allowed at server startup) and run imports (interactive, pinned and watched folders).

See Beets import for the details.

Beets configuration and import

Notifications

Create and manage notifications sent through Apprise (imports, blocked logins, health scans, new shares…).

Notifications

Maintenance

Trigger a library health scan (see Encoding errors detection & self-healing streams), cleanup BeetstreamNext’s database, clear disk caches, and view the server logs.

Maintenance

Configuration reference

BeetstreamNext settings can be set from various ways:

  1. Explicit sources:

    • via CLI flag
    • via an environment variable
    • in plugin mode: via Beets’ config.yaml, under a beetstreamnext: block (see Installation)
    • in standalone mode: via BeetstreamNext’s config YAML (see Installation)
  2. The Admin WebUI: Only for settings that aren’t already pinned by one of the explicit sources above. When a setting is pinned by any of those sources, its field in the Admin panel is locked/disabled.

Settings resolve order (first one set takes precedence):

  • in plugin mode:
flowchart LR
    A("CLI flag") --> B("environment variable") --> C("Beets' config's <code>beetstreamnext:</code> block") --> D("WebUI setting") --> E("Built-in default")

style A fill:#ffffff12,stroke:#808080
style B fill:#ffffff12,stroke:#808080
style C fill:#ffffff12,stroke:#808080
style D fill:#ffffff12,stroke:#808080
style E fill:#ffffff12,stroke:#808080
  • in standalone mode:
flowchart LR
    A("CLI flag") --> B("environment variable") --> C("BeetstreamNext's YAML config file") --> D("Beets' config file<br/><br/>(if you point it at one, and only for <code>library-db</code> and <code>music-root</code>)") --> E("WebUI setting") --> F("Built-in default")

style A fill:#ffffff12,stroke:#808080
style B fill:#ffffff12,stroke:#808080
style C fill:#ffffff12,stroke:#808080
style D fill:#ffffff12,stroke:#808080
style E fill:#ffffff12,stroke:#808080
style F fill:#ffffff12,stroke:#808080

For list-based settings (like for example ip_whitelist/ip_blacklist): pinning is per individual entry.

Settings marked requires restart only take effect after the server is restarted. Settings marked standalone only are not used when running as a Beets plugin.

Note

BeetstreamNext checks at startup whether the music folder and the beets library database are writable on disk.

If either isn’t (for example when they’re bind-mounted :ro, as in the Betanin example stack), the WebUI will show a red “read-only” pill next to any setting that would otherwise need write access.

External services

lastfm_api_key

Last.fm API key for fetching metadata.

sensitive

type: str

default: (empty)

env: BSN_LASTFM_API_KEY


lastfm_shared_secret

Last.fm API shared secret (only needed for Last.fm and Libre.fm scrobbling, with the API key above).

sensitive

type: str

default: (empty)

env: BSN_LASTFM_SHARED_SECRET


external_scrobbling

Let users connect their external scrobbling accounts (ListenBrainz, Last.fm, Libre.fm) and share what they play.

type: bool

default: True

env: BSN_EXTERNAL_SCROBBLING


audiomuse_api_token

API token for your AudioMuse-AI instance.

sensitive

type: str

default: (empty)

env: BSN_AUDIOMUSE_API_TOKEN


audiomuse_url

URL to your AudioMuse-AI instance (e.g. http://localhost:8000) to enable sonic similarity endpoints.

type: str

default: (empty)


podcastindex_api_key

API key for the Podcast Index, used for podcast channel discovery.

sensitive

type: str

default: (empty)

env: BSN_PODCASTINDEX_API_KEY


podcastindex_api_secret

API secret for the Podcast Index.

sensitive

type: str

default: (empty)

env: BSN_PODCASTINDEX_API_SECRET

Server & network

host

Host(s) to listen on (comma-separated for multiple), or unix:/path/to/socket (to use a Unix socket, reverse_proxy has to be enabled)

requires restart

type: list[str]

default: 0.0.0.0

env: BSN_HOST


port

Port to listen on.

requires restart

type: int

default: 8080

env: BSN_PORT


public_hosts

Addresses of host that only serve public pages (shares, tokenised links, health check, homepage). Not compatible with a wildcard host (0.0.0.0).

requires restart

type: list[str]

default: (empty)

env: BSN_PUBLIC_HOSTS


public_port

Extra port that only serves public pages on the other addresses of host. 0 disables it.

requires restart

type: int

default: 0

env: BSN_PUBLIC_PORT


admin_hosts

Addresses of host that serve the admin panel and control API.

requires restart

type: list[str]

default: (empty)

env: BSN_ADMIN_HOSTS


admin_port

Extra port that serves the admin panel and control API on the other addresses of host. 0 disables it.

requires restart

type: int

default: 0

env: BSN_ADMIN_PORT


external_hostname

Public hostname for share links and tokenised stream/image URLs (e.g. public.example.com). If unset, the users-accessible address is used (unless reverse_proxy is on), then the hostname of the request.

type: str

default: (empty)

env: BSN_EXTERNAL_HOSTNAME


threads

Worker threads for serving requests. Each open admin panel tab holds one thread for its live updates, so raise this if several admins are expected.

requires restart

type: int

default: 16

env: BSN_THREADS


channel_timeout

Seconds of inactivity allowed on a connection before Waitress closes it. Lower this on low-resource environments to free up connections faster.

requires restart

type: int

default: 120


connection_limit

Maximum number of simultaneous connections Waitress will accept. Lower this on low-resource environments to cap memory/socket usage.

requires restart

type: int

default: 100


cors_origins

Allowed CORS origins (comma-separated, * for all). Empty to disable CORS.

requires restart

type: str

default: (empty)

env: BSN_CORS_ORIGINS


cors_supports_credentials

Allow CORS requests with credentials (cookies, HTTP auth).

requires restart

type: bool

default: False

env: BSN_CORS_CREDENTIALS


reverse_proxy

Server is behind a reverse proxy (Nginx, Caddy, Traefik, etc.).

requires restart

type: bool

default: False

env: BSN_REVERSE_PROXY


proxy_hops

Number of trusted reverse proxies in front of the server. Only used if reverse_proxy is enabled

requires restart

type: int

default: 1

env: BSN_PROXY_HOPS


trusted_proxies

IPs/CIDR ranges of reverse proxies allowed to set X-Forwarded-* headers. Headers from requests from other addresses are ignored.

Only used if reverse_proxy is enabled. If empty, every peer is trusted. Unix sockets are always trusted.

requires restart

type: list[str]

default: (empty)

env: BSN_TRUSTED_PROXIES


sendfile_method

Offload direct (non-transcoded) file serving to the reverse proxy instead of streaming bytes through Python. Use x-accel-redirect for Nginx, x-sendfile for Apache.
Only takes effect when reverse_proxy is enabled and the proxy is configured to honor the header.

type: str

default: off, choices: off, x-accel-redirect, x-sendfile

env: BSN_SENDFILE_METHOD


sendfile_internal_prefix

Internal URI prefix your Nginx config maps, via an internal-only location block, to the music root directory.
Only used when sendfile_method is x-accel-redirect.

type: str

default: /_bsn_internal

env: BSN_SENDFILE_INTERNAL_PREFIX


trusted_hosts

Allowed Host headers (domain names/IPs, comma-separated).
If empty, all hosts are allowed. Setting this is recommended if the server reachable beyond your own machine.
Loopback is always allowed.

type: str

default: (empty)

env: BSN_TRUSTED_HOSTS


legacy_auth

Allow legacy MD5 token / cleartext password authentication. API-key authentication always works.

type: bool

default: False

env: BSN_LEGACY_AUTH


api_user_management

Allow admins to create, update and delete users, and change other users’ passwords, through the Subsonic API (createUser, updateUser, deleteUser, changePassword). The admin panel is not affected.

type: bool

default: False

env: BSN_API_USER_MANAGEMENT


public_now_playing

Show the currently playing song on the public home page.

type: bool

default: False


Show the “View on GitHub” link on the public home page.

type: bool

default: True


Show the “Read the docs” link on the public home page.

type: bool

default: True


homepage_connect_hint

Show the “Connect your favourite client using this URL” hint and server URL on the public home page.

type: bool

default: True


homepage_stats

Show the artists / albums / songs counters on the public homepage.

type: bool

default: True


homepage_subtitle

Subtitle shown under the title on the public homepage. Leave empty to hide it.

type: str

default: Your Beets library, streaming anywhere.


music_root

Root directory your beets library’s file paths are relative to.

standalone only, requires restart

type: str

default: (empty)


library_path

Path to the beets library.db to serve.

standalone only, requires restart

type: str

default: (empty)


library_remote_path

Optional, mount point used by another container that accesses the beets library (e.g. Betanin), if it differs from where ‘music_root’ mounts it in the BeetstreamNext container. Leave empty if both containers mount the music volume at the same path.

standalone only

type: str

default: (empty)

env: BSN_LIBRARY_REMOTE_PATH


playlist_dir

Directory where BeetstreamNext writes its playlists as .m3u files (per-user subfolders for user playlists). Existing files in it are imported once.

Leave empty to disable.

requires restart

type: str

default: (empty)

env: BSN_PLAYLIST_DIR


strict_beets_version_check

Refuse to start on beets version mismatch (see Server Info) instead of just warning.

standalone only, requires restart

type: bool

default: False

Library & metadata

allow_disk_writes

Required to allow beet import scans to modify content on disk (writing tags in the files or copying/moving files). Not needed when the loaded Beets config already has write/copy/move all disabled.

type: bool

default: False

env: BSN_ALLOW_DISK_WRITES


allow_beets_config_edit

Allow editing the Beets config file from the admin panel.
A Beets config could load plugins and run shell commands when an import runs,so whoever can edit it could run code on the server.
This setting is not modifiable from the Web UI.

type: bool

default: False

env: BSN_ALLOW_BEETS_CONFIG_EDIT


control_api_token

Token for the control API (/control-api), which lets external tools and scripts trigger actions on this server (e.g. a Beets import triggered by a download client when a download finishes).

The control API is disabled if this is empty.

sensitive

type: str

default: (empty)

env: BSN_CONTROL_API_TOKEN


import_watch_enabled

Enable automatic Beets import for new files in configured watched folders.

type: bool

default: False

env: BSN_IMPORTWATCH_ENABLED


import_watch_settle

How long to wait (in seconds) for a watched folder to stop changing before importing it (to avoid importing half-copied or half-downloaded albums).

type: int

default: 60

env: BSN_IMPORTWATCH_SETTLE


never_transcode

Never transcode files, always stream the original.

type: bool

default: False

env: BSN_NEVER_TRANSCODE


fetch_artists_images

Fetch missing artist images from external services.

type: bool

default: False

env: BSN_FETCH_ARTISTS_IMAGES


fetch_artists_biographies

Fetch artist short biography from Wikipedia.

type: bool

default: False


save_album_art

Also save fetched album art on disk, alongside music files (in addition to the cache).

type: bool

default: False

env: BSN_SAVE_ALBUM_ART


follow_playlist_embedded_urls

Fetch external images from foreign playlists’ #EXTALBUMARTURL lines (for albums that have no art locally). Only enable this if you trust the source of your imported playlists.

type: bool

default: False


fetch_lyrics

Fetch missing song lyrics using Beets’ Lyrics plugin.

type: bool

default: False


save_lyrics

Save fetched lyrics to the beets library database (this never writes tags into the music files, only to the Beets database).

type: bool

default: False


fetch_album_version

Fetch album version info (“Deluxe Edition”, “Japanese Expanded Edition”, etc.) from MusicBrainz.

type: bool

default: False


save_album_version

Save fetched album version info to the beets database (this never writes tags into the music files, only to the Beets database).

type: bool

default: False


discogs_ratings

Use Discogs’ public community rating for an album’s average rating.

fallback: Only use Discogs when nobody on this server has rated the album locally. It never overrides a local rating.

prefer: Always uses Discogs when available (falling back to the local average when it isn’t).

type: str

default: off, choices: off, fallback, prefer


ignored_articles

Space-separated articles (across any language) to ignore when sorting artists alphabetically (for instance, “The Beatles” -> B).

type: str

default: The A An Der Die Das Ein Eine El La Los Las Un Una Le Les Il Lo Gli Uno O Os As Um Uma De Het Den Det


ratings_writeback_user

Commit this user’s Likes and Ratings into the Beets library so they survive outside BeetstreamNext. Beets has no concept of per-user data, so only one user’s changes can be committed this way (this never writes tags into the music files, only to the Beets database).
Leave unset to disable.

type: str

default: (empty)


external_playlists_editors

Who can rename/edit/delete non-BeetstreamNext playlists (from Beets’ playlist plugin directory).

smartplaylist-generated playlists are always read-only.

type: list[str]

default: (empty)


enable_radio_discovery

Enable Radio Browser API for station discovery.

type: bool

default: False


fetch_radio_images

Automatically fetch station icon when adding a new radio station.

type: bool

default: True

Podcasts

podcast_storage_dir

Directory to store downloaded podcast episode audio. Leave empty to use the default data location.

type: str

default: (empty)


podcast_auto_download_count

Number of episodes to download of a channel’s most recent episodes when added. Set to 0 to disable and only download episodes on request.

type: int

default: 3


enable_podcast_discovery

Enable Podcast Index API for channel discovery.

type: bool

default: False

Audio & jukebox

replaygain_enabled

Apply ReplayGain normalization on the server side.

type: bool

default: False


replaygain_preamp

Additional gain (dB) to apply.

type: int

default: 0


replaygain_fallback

Gain (dB) to apply to tracks without ReplayGain tags in beets’ library.

type: int

default: -6


audio_peak_limit

Always prevent audio peaks from exceeding 0 dB (prevent clipping).

type: bool

default: False


ffmpeg_path

Path to the ffmpeg binary, if it isn’t on the system PATH (e.g. /usr/local/bin/ffmpeg).
Leave empty to auto-detect from PATH.
This setting is not modifiable from the Web UI.

type: str

default: (empty)

env: BSN_FFMPEG_PATH


jukebox_allowed

Allow jukebox mode: the server can play audio on its own hardware, or on a Sonos/Chromecast compatible speaker (client apps act as remote controls).

type: bool

default: False


jukebox_backend

Defines where jukebox mode will play audio from.
server_hardware plays on this server’s own audio hardware using mpv.
sonos or chromecast stream to a speaker on the local network.

type: str

default: server_hardware, choices: server_hardware, sonos, chromecast


jukebox_hardware_device

Which hardware jukebox mode plays audio on.
For server_hardware, this is the audio output device as mpv’s –audio-device expects (e.g. alsa/hw:0,0 or coreaudio/BuiltInSpeakerDevice), or empty to use the system default.
For sonos, the speaker’s IP address.
For chromecast, either the device’s UUID (found via mDNS/Zeroconf discovery), or its IP address/hostname to connect to it directly.

type: str

default: (empty)


mpv_path

For server_hardware backend only. Path to the mpv binary, if it isn’t on the system PATH (e.g. /usr/local/bin/mpv).
Leave empty to auto-detect from PATH.
This setting is not modifiable from the Web UI.

type: str

default: (empty)

env: BSN_MPV_PATH

Security

restrict_control_api

Only serve the control API (/control-api) on the admin scope listeners. Turn off to also serve it on the users scope.

type: bool

default: True

env: BSN_RESTRICT_CONTROL_API


local_services

Services on the local network that may be contacted: hostnames, IP addresses or CIDR ranges (comma-separated), for example localhost, 192.168.1.0/24.

Connections to private addresses are refused unless they are listed here. Ports are not restricted.

type: list[str]

default: (empty)

env: BSN_LOCAL_SERVICES


ip_whitelist

Allowed IPs (empty = allow all except blacklist).

type: list[str]

default: (empty)

env: BSN_IP_WHITELIST


ip_blacklist

Banned IPs.

type: list[str]

default: (empty)

env: BSN_IP_BLACKLIST


rate_limit_max_failures

Failed attempts before an IP is rate-limited.

type: int

default: 5


rate_limit_block_window

Seconds before failures roll off.

type: int

default: 300


rate_limit_ip_max_failures

Failed attempts from a single IP (across any usernames tried) before that IP is blocked outright. Catches attackers rotating usernames to dodge the per-user limit above.

type: int

default: 20


rate_limit_ip_block_window

Seconds before an IP-wide failure count rolls off.

type: int

default: 3600

Shares

share_default_expiry_days

Days until new shares expire, unless the client sets an expiry (0 = never).

type: int

default: 0


share_default_max_downloads

Download limit for new shares (0 = unlimited).

type: int

default: 0


Add metadata to share pages to allow chat apps (Discord, Slack, Telegram, …) show a preview with the title and cover art (their servers fetch the page and cover without logging in).

type: bool

default: True

env: BSN_SHARE_LINK_PREVIEWS


share_default_password

Password for new shares (visible to share owners on their account page).

type: str

default: (empty)

Reverse proxy & CORS

BeetstreamNext reads standard HTTP headers to determine the original client’s IP, so putting it behind a reverse proxy is straightforward.

Enable the reverse_proxy option (see configuration) so it trusts those forwarded headers, and set the proxy_hops to the number of trusted proxies in front of it.

Unix socket

With the proxy on the same machine (or sharing a volume with the container), BeetstreamNext can listen on a unix socket instead of a port.

Set host to unix:/absolute/path/of/socket:

BSN_HOST=unix:/run/beetstreamnext/bsn.sock

The socket is created with 660 permissions: add the proxy’s user to the group that runs BeetstreamNext.

  • reverse_proxy has to be on, the server refuses to start otherwise: every request via a socket looks local, so the real client addresses can only come from the proxy’s forwarded headers.
  • It can’t be combined with other host entries
  • It isn’t available on Windows, nor in debug mode.
  • The folder must exist and be writable by BeetstreamNext, and an old socket file from a previous run is replaced.
  • In Docker, mount the socket’s folder as a volume shared with the proxy.

Restricting which peers are trusted

By default, with reverse_proxy on, X-Forwarded-* headers are trusted from any peer that can reach the server. If the port is reachable by anything but the proxy, set trusted_proxies to the proxy’s address(es) (e.g. 172.18.0.0/16 for a Docker network). Headers from any other address are then ignored, so a spoofed X-Forwarded-For can’t change the client IP seen by the rate limiter and IP filters.

Nginx

Example configuration:

location /beetstreamnext {
    proxy_pass http://127.0.0.1:8080;  # or http://unix:/run/beetstreamnext/bsn.sock if using a Unix socket

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # If hosting in a subfolder, tell BeetstreamNext what the subfolder is!
    proxy_set_header X-Forwarded-Prefix /beetstreamnext;
}

Tip

The admin panel’s live views (the import log, notifications, etc) use a long-lived text/event-stream connection. If your proxy ignores the X-Accel-Buffering header, you should set proxy_buffering off; for the admin location, otherwise live updates can show up late.

Caddy 2

Caddy passes all the required headers by default:

example.com {
    reverse_proxy 127.0.0.1:8080
}

(or reverse_proxy unix//run/beetstreamnext/bsn.sock if using a Unix socket)

Traefik with Docker

A common setup: the admin panel on a private hostname, the Subsonic API and accounts on another, and only the public routes (shares, tokenised links) on a public one.

You can achieve this easily with a port for each scope (port, public_port and admin_port), and one Traefik router for each.

services:
  beetstreamnext:
    image: ghcr.io/florentlm/beetstreamnext:latest
    container_name: beetstreamnext
    restart: unless-stopped
    environment:
      PUID: "1000"
      PGID: "1000"
      BEETS_LIBRARY_DB: /data/library.db
      MUSIC_ROOT: /music
      BSN_PORT: 8080                               # Subsonic API, login, account pages
      BSN_PUBLIC_PORT: 8081                        # Public routes only
      BSN_ADMIN_PORT: 8082                         # Admin panel and control API
      BSN_REVERSE_PROXY: "true"
      BSN_PROXY_HOPS: 1                            # if Traefik is the only proxy
      BSN_EXTERNAL_HOSTNAME: share.example.com     # Host used in share links
    volumes:
      - ./config:/config
      - /path/to/library.db:/data/library.db:ro
      - /path/to/music:/music:ro
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.docker.network=proxy
      
      # Public: shares and tokenised links, on a port that serves nothing else
      - traefik.http.routers.bsn-public.rule=Host(`share.example.com`)
      - traefik.http.routers.bsn-public.entrypoints=websecure
      - traefik.http.routers.bsn-public.tls.certresolver=letsencrypt
      - traefik.http.routers.bsn-public.service=bsn-public
      - traefik.http.services.bsn-public.loadbalancer.server.port=8081

      # Subsonic clients and account pages
      - traefik.http.routers.bsn.rule=Host(`music.example.com`)
      - traefik.http.routers.bsn.entrypoints=websecure
      - traefik.http.routers.bsn.tls.certresolver=letsencrypt
      - traefik.http.routers.bsn.service=bsn
      - traefik.http.services.bsn.loadbalancer.server.port=8080

      # Admin panel and control API
      - traefik.http.routers.bsn-admin.rule=Host(`admin.example.com`)
      - traefik.http.routers.bsn-admin.entrypoints=websecure
      - traefik.http.routers.bsn-admin.tls.certresolver=letsencrypt
      - traefik.http.routers.bsn-admin.service=bsn-admin
      - traefik.http.routers.bsn-admin.middlewares=admin-allowlist
      - traefik.http.middlewares.admin-allowlist.ipallowlist.sourcerange=192.168.0.0/16,100.64.0.0/10
      - traefik.http.services.bsn-admin.loadbalancer.server.port=8082

networks:
  proxy:
    external: true

(This assumes a Traefik container on the external proxy network, with a websecure entrypoint and a letsencrypt certificate resolver. Adjust to your configuration.)

  • Each router reaches a different port, and each port serves a different scope: 8081 the public routes, 8080 also the Subsonic API and account pages, 8082 also the admin panel and control API. A request for share.example.com with a spoofed Host: admin.example.com header still only gets public routes.

  • The admin-allowlist middleware limits who can reach the admin hostname (an IP allow-list here, or forward-auth with Authelia/Authentik, etc.).

  • External scripts that use the control API (/control-api) to trigger imports must reach the admin port. Note: I might make this more flexible again

  • BSN_PROXY_HOPS is the number of proxies in front of BeetstreamNext. Use 2 or more if something else (Cloudflare, for instance) is in front of Traefik, or if you offload file serving with another proxy.

Note

To achieve similar thing without Docker, on a host with several addresses, use public_hosts and admin_hosts.

Credentials in proxy logs

The Subsonic protocol sends credentials as URL query parameters: p (password), t and s (token and salt), and apiKey. Creating or updating a user over the API also puts the new password in the query string.

BeetstreamNext redacts these from its own logs, but your reverse proxy sees the full URL, and its access log will contain them in clear text (and so will any other log or monitoring tool in front of BeetstreamNext). Anyone who can read those logs can read your users’ passwords and API keys.

To avoid it, either turn access logging off, or log the path only and leave the query string out.

Nginx: log $uri instead of $request/$request_uri:

log_format bsn '$remote_addr [$time_local] "$request_method $uri" $status $body_bytes_sent';
access_log /var/log/nginx/beetstreamnext.access.log bsn;

Apache: log %U (path only) instead of %r:

LogFormat "%h %t \"%m %U\" %>s %b" bsn
CustomLog logs/beetstreamnext_access.log bsn

Caddy: replace the sensitive parameters in the log:

example.com {
    log {
        format filter {
            wrap json
            fields {
                request>uri query {
                    replace p REDACTED
                    replace t REDACTED
                    replace s REDACTED
                    replace apiKey REDACTED
                    replace password REDACTED
                }
            }
        }
    }
    reverse_proxy 127.0.0.1:8080
}

Prefer clients that support API keys (apiKey) over passwords where possible, and give them an app password rather than the account password: an app password can be revoked (and limited to read-only access), whereas a leaked account password is also the user’s web UI login (and, for admins, the admin panel login).

Tip

You should also just avoid using an account with the Admin role in a Subsonic client (create a non-admin account for that).

Also, use HTTPS so the URLs aren’t readable on the network.

Offloading file serving to the proxy

Instead of streaming raw file bytes through the Python process, the reverse proxy can serve them directly.

This only takes effect when reverse_proxy is enabled, and only helps for direct (non-transcoded) playback/downloads.

Set sendfile_method to x-accel-redirect for Nginx, or x-sendfile for Apache.

Nginx (x-accel-redirect)

Add an internal-only location block aliased to your music root, and point sendfile_internal_prefix at it:

location /_bsn_internal/ {
    internal;
    alias /path/to/your/music/;
}
beetstreamnext:
    sendfile_method: x-accel-redirect
    sendfile_internal_prefix: /_bsn_internal

/_bsn_internal is the default for sendfile_internal_prefix.

Apache (x-sendfile, via mod_xsendfile)

XSendFile On
XSendFilePath /path/to/your/music/
beetstreamnext:
    sendfile_method: x-sendfile

sendfile_internal_prefix isn’t used with x-sendfile.

Full working example: Nginx sidecar for a Docker deployment

Putting everything together, a minimal nginx.conf that reverse-proxies a beetstreamnext container on the same Docker network and offloads direct file serving via X-Accel-Redirect:

worker_processes 1;
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    access_log off;

    server {
        listen 8080;

        location /_bsn_internal/ {
            internal;
            alias /music/;
        }

        location / {
            proxy_pass http://beetstreamnext:8888;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

Here 8888 can be whatever port (BSN_PORT/port setting) BeetstreamNext is listening on internally, only Nginx’s port needs to be published to the host.

/music must be the same music folder mounted into both the nginx and beetstreamnext containers, with the same relative layout under each mount point (but the absolute host path doesn’t need to match).

The matching BeetstreamNext settings:

beetstreamnext:
    reverse_proxy: true
    proxy_hops: 2   # The number of proxies in front of BeetstreamNext
    sendfile_method: x-accel-redirect
    sendfile_internal_prefix: /_bsn_internal

Important

For proxy_hops, use 1 if the Nginx container is the only proxy, more if Nginx is itself behind a reverse proxy (Traefik, Cloudflare, etc)

Web clients and CORS

By default, CORS is disabled. Native mobile/desktop apps usually ignore CORS entirely, so you probably don’t need to change anything for those.

However, if you want to use a web-based Subsonic player hosted on a different domain than BeetstreamNext, your browser will block the connection unless you explicitly allow that origin:

beetstreamnext:
    cors_origins: 'https://music.example.com' # comma-separated list, or '*' for all

If you’re behind an SSO gateway (Authelia, Authentik, etc), or if the web player is a bit quirky, you might also need:

beetstreamnext:
    cors_supports_credentials: true

Caution

️ Never set cors_origins: '*' together with cors_supports_credentials: true. Doing so would allow any website you visit to silently interact with your BeetstreamNext server in the background.

API coverage

Tip

See Features for what BeetstreamNext adds on top of the spec itself :)

BeetstreamNext implements essentially the entire Subsonic/OpenSubsonic REST API. The only unsupported endpoints are video-related:

Full endpoint-by-endpoint checklist

Important

Following the reference Subsonic specification, error responses are returned with an HTTP 200 status. The real outcome is in the response body’s status field ("ok" or "failed", with an error code/message on failure).

Extensions

Alongside the base spec, BeetstreamNext implements all the OpenSubsonic extensions.

ExtensionDescription
apiKeyAuthenticationAuthenticating with an API key alone, no password
formPostAccepting requests as application/x-www-form-urlencoded POST bodies
getPodcastEpisodeRetrieving a single podcast episode’s metadata by ID
indexBasedQueueSetting/reading the play queue by index instead of only by ID
playbackReportClients reporting their playback timeline back to the server
songLyricsSynchronized, multi-language lyrics, retrievable by song ID
topSongsByArtistIdRetrieving an artist’s top songs by artist ID
transcodeOffsetStarting a transcode from a given time offset
transcodingClients making their own transcoding decisions and requesting transcoded streams directly
sonicSimilarityAcoustic similarity and playlist path-finding (see Library augmentation)

Note

These extensions are always advertised by the server except for sonicSimilarity which only appears if an AudioMuse-AI instance is configured.

Authentication

Both authentication schemes from the spec are supported:

  • API-key authentication Always available, and recommended. See app passwords.
  • Legacy MD5-token / cleartext password authentication: For older clients. Can be enabled server-wide via legacy_auth if your client needs it. It only accepts app passwords created with Usable as a password.

Tested clients

BeetstreamNext should be compatible with virtually any Subsonic/OpenSubsonic/Navidrome client.

The Navidrome project maintains a nice list of existing clients here.

I specifically tested it with:

Android

iOS

Desktop

Note

Not seeing a client here doesn’t mean it won’t work, this is just what I have manually verified. If you confirm another client works well, consider opening a PR or an Issue to let me know :)

Planned features

In no particular order:

  • Jukebox: Web UI control panel, and more backends (Snapcast, DLNA/UPnP, AirPlay)
  • Direct playlist push to Spotify, Deezer, Tidal, etc
  • Augmented public shares for read-only setups (serve versions with clean tags and embedded art)
  • Share options: Selectable format/bitrate for shares downloads
  • Proper OIDC support (Authelia, Authentik, etc)
  • Prometheus metrics

Privacy

BeetstreamNext collects nothing about you. There are no ads, no telemetry, no analytics, no crash reporting and no update check.

tl;dr: I never see your data. I don’t care about it.

What is stored on your server

Everything stays in your Beets library and BeetstreamNext’s own database, on your machine:

  • User accounts (usernames, password hashes, and app passwords),

  • Ratings, likes, playlists, bookmarks, play queues, play counts and chat messages

  • Web UI sessions, including the IP address and user agent they were opened from

  • The server log, which can contain IP addresses and requested URLs

Nothing is sent anywhere unless you enable one of the features below.

Outgoing requests

Some features contact third-party services from your server (never from the browser). These requests carry names of artists, albums or feeds from your library, and your server’s IP address. They never include account or listening data (except for external scrobbling of course).

FeatureServiceWhat is sent
Metadata and album versionsMusicBrainz, Cover Art ArchiveRelease / artist identifiers and names
Artist and album infoLast.fmArtist / album names and your lastfm_api_key
ScrobblingListenBrainzWhat you play, when, and your ListenBrainz token
ScrobblingLast.fm, Libre.fmWhat you play, when, and your session key
Album ratingsDiscogsRelease identifiers
Artist imagesDeezerArtist / album names
Radio stationsRadio Browser, the station’s own website, DuckDuckGo iconsStation names and domains (for favicons)
PodcastsThe feed and media hosts you subscribe to, and Podcast Index for searchFeed URLs and search terms
NotificationsWhatever Apprise service you configureThe notification content
Sonic similarityYour own AudioMuse-AI instanceTrack identifiers

Each service has its own privacy policy. Features you don’t use don’t make any requests, and the corresponding settings can be turned off in the configuration.

The reverse also applies: when a public share link is posted in a chat app, that platform’s servers fetch the share page and its cover to build a preview. You can turn this off with share_link_previews.

The web UI

The web UI loads everything (scripts, styles and fonts) from your own server. It doesn’t use third-party CDNs, trackers or cookies other than the session cookie needed to stay logged in.

This documentation site and the repository

The documentation is a static site hosted on GitHub Pages. It has no analytics and no cookies of its own, and loads no third-party scripts or fonts.

Questions

If something here is unclear or wrong, please open an issue.