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
- Features: full API coverage (including internet radio, podcasts, playlists, public shares, multi-user accounts & permissions, jukebox mode…), along with BeetstreamNexts’s additional features.
- Installation: Installation instructions, as a Beets plugin or as a standalone server.
- Usage: The CLI commands (plugin mode and standalone mode) and a quick tour of the Web UI with screenshots.
- Configuration reference: Every setting by category, and instructions on reverse proxy & CORS setup.
- Reference: Details of implemented endpoints, planned features and tested clients.
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
| Area | Additions over Beets | More details |
|---|---|---|
| Streaming | Direct play, on-the-fly transcoding, adaptive-bitrate HLS, server-side ReplayGain, self-healing streams | Streaming & audio quality |
| Metadata & artwork | Artist bios, top tracks, similar artists/songs, album/artist ratings, lyrics, album editions, sonic similarity | Library augmentation |
| Multi-user | Multi-user auth, user roles, bookmarks/ratings/favorites/queues that sync across devices, chat features | Accounts, permissions & chat |
| Non-library audio | Internet radio stations, podcast subscriptions & downloads | Radio & podcasts |
| Scrobbling | Send your listens to ListenBrainz | Scrobbling |
| Playlists | BeetstreamNext’s own per-user playlists, and read/write access to Beets’ playlist/smartplaylist files | Playlists |
| Sharing | Public landing pages and download links (no client or account required) | Sharing |
| Security | IP allow/deny lists, adaptive rate-limiting | Security |
| Jukebox mode | OpenSubsonic’s jukebox mode, augmented by Sonos and Chromecast support | Jukebox mode |
| Beets import | Betanin-like interactive or non-interactive imports (also triggerable from Subsonic clients) and watched-folder auto-import | Beets import |
| Notifications | Apprise notifications (Discord, Telegram, ntfy, email, webhooks, etc) when imports start, finish, fail, or need input | Notifications |
| Advanced Beets queries | Prefix any search from your client app with beets: or b: to run a complex Beets query instead of simple search | Library augmentation |
External data sources
Additional data and library augmentation comes from third-party services (they are all entirely optional):
| Data | Source | Enabled by |
|---|---|---|
| Artist biography | Last.fm (if lastfm_api_key is set), or Wikipedia | fetch_artists_biographies |
| Artist top tracks & similar artists/songs | Last.fm | lastfm_api_key |
| Artist images | Deezer’s public search API | fetch_artists_images |
| Missing album art | Cover Art Archive | fetch_artists_images/save_album_art |
| Album/artist community rating | Discogs’ public rating | discogs_ratings |
| Album edition/version info (e.g. “Deluxe Edition”) | MusicBrainz | fetch_album_version |
| Acoustic similarity & playlist path-finding | AudioMuse-AI (your own self-hosted instance) | audiomuse_url and audiomuse_api_token |
| Radio station / podcast favicons | The station/feed’s site, falling back to DuckDuckGo’s icon proxy | automatic, when an icon isn’t supplied |
| Internet radio station discovery | Radio Browser | enable_radio_discovery |
| Podcast channel discovery | Podcast Index | enable_podcast_discovery |
| Song Lyrics | Uses Beets’ lyrics plugin’s configured external sources | fetch_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_keyis set, otherwise from Wikipedia iffetch_artists_biographiesis 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
.lrcfiles next to the audio files (tip: use the filetote plugin), or fetched on-the-fly using Beets’lyricsplugin (fetch_lyrics), optionally written back into Beets withsave_lyrics. -
Album version/edition info (e.g. “Deluxe Edition”, “Japanese Expanded Edition”): From MusicBrainz, via
fetch_album_version, optionally saved withsave_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_artallows saving album art next to the music files. -
follow_playlist_embedded_urlslets cover art be pulled from a foreign playlist’s#EXTALBUMARTURLlines, 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_authif 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):
| Role | Default | Allows |
|---|---|---|
| Admin | off | Full access to the admin panel and all settings |
| Settings | on | Changing their own personal settings and password |
| Stream | on | Playing files |
| Download | off | Downloading files |
| Playlists | on | Creating and deleting their own playlists |
| Comments | on | Creating and editing comments and ratings |
| Podcasts | off | Managing their own podcast subscriptions |
| Sharing | off | Generating public shares (see Sharing) |
| Jukebox | off | Controlling jukebox-mode playback (see Jukebox) |
| Scrobbling | on | Keeping 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
adminscope (seeadmin_hostsandadmin_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, ort+stokens), if you tick Usable as a password when creating it andlegacy_authis 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
| Access | Allows |
|---|---|
| Full access | Everything (whihin the user’s capabilities) |
| Read only | Browsing, 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_enabledturns this on. -
replaygain_preampadds extra gain (dB) on top of the tag value. -
replaygain_fallbackis the gain applied to tracks that have no ReplayGain tag in Beets. -
audio_peak_limitprevents 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:
-
BeetstreamNext’s own playlists: Created via the standard Subsonic playlist endpoints, and stored in BeetstreamNext’s database. Any
.m3ufiles already present inplaylist_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
.m3ufile inplaylist_dirso other players can use them. Leaveplaylist_dirsetting empty to not write any. -
Beets’
playlistplugin 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. -
Beets’
smartplaylistplugin directory: Query-generated playlists. These are read-only in BeetstreamNext, because they’re automatically regenerated by Beets.
Permissions
-
Any user with
playlistRolecan 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_editorscan also edit/delete playlists from the Beetsplaylistplugin directory. Playlists generated bysmartplaylistplugin 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:
-
Export as CSV from the Web UI, and upload it to Tune My Music
-
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
-
On ListenBrainz, copy your user token from your settings page.
-
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:
-
In the Scrobble card, click Connect on the service.
-
Click Open Last.fm, and approve access to your account on their site.
-
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
| When | What |
|---|---|
| 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,usersoradmin) that sets which routes it serves, based on the address and port it listens on. See Listener scopes.-
public_hostsandpublic_portgive listeners thepublicscope (public routes). -
admin_hostsandadmin_portgive listeners theadminscope (admin panel and control API). -
The other addresses have the
usersscope. -
restrict_control_api(on by default) keeps the control API onadminscope listeners. Turn it off to also serve it onusersones (it is still protected by its token). -
Remark: By default, a single listener serves everything.
-
-
Trusted
Hostheaders:trusted_hostscontrols whichHostheader values are accepted.-
Remark: If the server is reachable beyond your own machine, it is recommended to set this. You can also use
external_hostnamefor share links. -
A
Hostheader that isn’t just ahostname[:port](scheme, path,user@, spaces, control characters…) is always refused (with HTTP 400).
-
-
Legacy authentication:
legacy_authcontrols 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
apiKeycan 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_managementcontrols 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_editmust 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
inlineandhookplugins,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,embedartorfetchartinautomode) requireallow_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_failuresfailed attempts withinrate_limit_block_windowseconds blocks that specific (IP, username) pair. -
Per-IP:
rate_limit_max_failuresfailed attempts from an IP (using any username) withinrate_limit_block_windowseconds 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.
| Scope | Serves |
|---|---|
public | the public routes |
users | the public routes, the Subsonic API, login and account pages |
admin | everything, 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]
| Address | Scope |
|---|---|
192.168.0.10 | public |
100.70.0.5 | users |
127.0.0.1 | admin |
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
adminlisteners.
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 on | Scope |
|---|---|
100.70.0.5:8080 | admin |
100.70.0.5:8081 | public |
192.168.0.10:8080 | public |
Good to know
-
These settings need a restart. With a Unix socket or in debug mode there is a single
adminlistener 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_discoveryto 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-icontags, 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
podcastRolecan manage their own subscriptions. -
Channel discovery: Set
enable_podcast_discoveryand configurepodcastindex_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 to0to 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:
| Backend | Plays on | Extra dependency |
|---|---|---|
server_hardware (default) | The server’s own audio output (via mpv) | mpv binary on PATH |
sonos | A Sonos speaker on the local network, via SoCo | pip install beetstreamnext[sonos] |
chromecast | A Chromecast device on the local network, via pychromecast | pip 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-devicevalue (e.g.alsa/hw:0,0on Linux,coreaudio/BuiltInSpeakerDeviceon 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/ipvlannetwork: 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_devicedirectly in the Web UI or inconfig.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_devicelikealsa/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_devicelikepulse): 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.
| Source | Interactive | Started from | Beets flags |
|---|---|---|---|
| Manually selected folders | Yes | Web UI | none (beets asks you what to do) |
| Pinned folders | No | Web UI, Subsonic client | -q, and --incremental or --noincremental |
| Watched folders | No | Automatically 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,copyandmoveare all disabled in your Beets config’simportsection (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_enabledsetting to be on -
Folders are polled every 30 seconds
-
A folder is only imported once its contents have stopped changing for
import_watch_settleseconds (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.
-
Set
control_api_token(orBSN_CONTROL_API_TOKEN) to a long random value (e.g. useopenssl rand -hex 24). -
Call
POST /control-api/trigger-importwhen a download completes (see examples below).
| Field | Description |
|---|---|
path | Absolute path of the folder to import (as seen by BeetstreamNext) |
interactive | Optional, 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
adminscope listeners by default. You can expose it on the user-facing interface by disablingrestrict_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
startScanalways 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 (
mergeduplicate 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
cataloghintis what triggered a skip, the import history and notifications will show the explanation it gives. -
cataloghinttracks 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
unskipperare independent from BeetstreamNext: they don’t appear in the import history, send no notifications, and may conflict with a running watched import. -
unskipperkeeps its own record next to the state file. Albums that BeetstreamNext retried or recorded are detected byunskipperat 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/smartplaylistfiles) 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_hostsoradmin_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.
| Kind | Blocked |
|---|---|
| Plugins | inline and hook (can’t be added to plugins) |
| Top-level keys | pluginpath, item_fields, album_fields |
| Command/binary options | hook.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.commandcan 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:
| Plugin | Considered unsafe when |
|---|---|
scrub | scrub.auto is on |
embedart | embedart.auto is on |
fetchart | fetchart.auto is on |
hook | always |
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:
| Field | Description |
|---|---|
| Type | What triggers it (see below) |
| Title | The message title, can use variables |
| Body | The message body, can use variables |
| URLs | One 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
eventcan 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
| Type | Sent when |
|---|---|
| Import started | A beet import run starts (from the admin panel, a pinned scan, a Subsonic client’s startScan, or a watched folder) |
| Import completed | An import finishes with exit code 0 |
| Import failed | An import finishes with a non-zero exit code |
| Import needs input | An interactive import is waiting for an answer (once each time it starts waiting) |
| Pinned scan aborted | A queued pinned folder scan couldn’t be started, and the rest of the queue was dropped |
| Login blocked | Too many failed logins locked out a user / IP |
| Health scan completed | A library health scan finishes (can be limited to scans where songs are flagged) |
| Health scan failed | A library health scan couldn’t run, or crashed |
| Maintenance task completed | A maintenance action from the admin panel (clear caches, database cleanup, offline files cleanup) succeeded |
| Maintenance task failed | A maintenance action from the admin panel failed |
| Share created | A user creates a share (from a Subsonic client) |
| Share accessed | A share’s public page is opened |
| Storage problem | Library files can’t be found on disk (checked on startup and after each health scan) |
| Server started | BeetstreamNext finished starting up (also after a restart) |
Note
A quiet watched-folder import exits with
0even 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:
| Variable | Value |
|---|---|
{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
cataloghintplugin, its explanation is used when it is what made the match unconfident.
Note
This companion plugin is automatically injected in the
beets importprocess 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_KEYthat 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(stillbeets-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
beetsavailable as a Python package (for its query engine and file-path handling).
-
-
ffmpeginstalled and available onPATH -
Optionally,
mpvinstalled and available onPATHfor jukebox mode using the server’s own audio hardware (not needed for thesonos/chromecastjukebox backends, or if you don’t use jukebox mode. See Jukebox info).
Note
Both
ffmpegandmpvbinaries can be pointed to explicitly if they aren’t on yourPATH, with theBSN_FFMPEG_PATH/BSN_MPV_PATHenvironment variables or theffmpeg_path/mpv_pathkeys 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-configcontains abeetstreamnext:section, it will be ignored in standalone mode.Put these settings in a dedicated BeetstreamNext
--configYAML file, or useBSN_*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(defaultall): which optional feature sets to install, comma-separated. Same list as above:wiki,podcasts,podcast-discovery,radio-discovery,sonos,chromecast,notifications, orall. -
PYTHON_VERSION(default3.13): Python version to use instead of the one defined inpyproject.toml. -
BEETS_VERSION:beetsto use instead of the one defined inpyproject.toml. -
WITH_MPV(defaulttrue): installmpv(needed for theserver_hardwarejukebox backend, see Jukebox mode). You can set tofalseif you don’t use that backend and want a smaller image. -
WITH_DEBUG_TOOLS(defaultfalse): also installcurl,wget,ping,dig/nslookup,nc, andip/ss, for poking at networking issues from inside the container (e.g.docker exec -it beetstreamnext curl ...).
Image paths
-
/configcontains:- the
beetstreamnext.yamlconfiguration file - the
.envfile (holdingBEETSTREAMNEXT_KEY, see Encryption key) - the BeetstreamNext database (
beetstreamnext.db) - a
data/subfolder: contains downloaded podcast episodes, and saved artist images
- the
-
/cacheis scratch space, with two subfolders:data: thumbnails, HTTP cache, session keytmp: transcode tempfiles, HLS sessions, zip downloads, SQLite/Python tempfiles
Tip
The
datacache subfolder is worth bind-mounting to keep the data across containers restarts.The
tmpsubfolder 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/configand/cachetoPUID/PGIDbefore 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/configis 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 (
beetsitself, 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(andmpv, unless disabled).Use a custom image
FROMthe 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.dbis 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.dbover 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
:rofor 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:/configmust point at the same host path as what you’ll use in the mainruncommand (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=1to that command to prevent it from writing the.envfile (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
tmpfsmount 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/cacheas 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
/configand/cachemounts 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): SetBEETSTREAMNEXT_KEYas 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
.envfile next to your database. Keep that file safe: set restrictive permissions if your platform doesn’t already (it’s created with0600), and make sure it’s excluded from anywhere the database itself isn’t equally protected.
Note
If
BEETSTREAMNEXT_KEYis 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_KEYfrom 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(withBSN_ADMIN_USER/BSN_ADMIN_PASSWORDset). 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]
| Flag | Effect |
|---|---|
| (none) | Start the server |
--host HOST[,HOST...] | Override host for this run (comma-separated for multiple) |
--port PORT | Override port for this run |
--threads N | Override how many threads the Waitress worker uses for this run |
--debug | Run in Flask debug mode |
--force-trust-host | Force debug mode even when not bound to localhost (NOT RECOMMENDED) |
-c, --create-user | Create a new user (interactive prompts) |
--noinput | With --create-user: non-interactive, see Unattended first run |
-u, --update-user USERNAME | Update an existing user’s roles (interactive prompts) |
-d, --delete-user USERNAME | Delete a user (asks for confirmation) |
-p, --password USERNAME | Change a user’s password (interactive prompts) |
--list-users | List all registered users and their roles |
--clear-cache | Clear the thumbnail and HTTP caches |
--backup FILE | Back 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]
| Command | Effect |
|---|---|
run | Start the server (default) |
create-user | Create 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-users | List all registered users and their key roles |
clear-cache | Clear the thumbnail and HTTP caches |
backup FILE | Back 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
commanddefaults torunif omitted
| Flags | Env var | Effect |
|---|---|---|
--config PATH | — | BeetstreamNext YAML config file (default location is OS-dependent, see here) |
--library-db PATH | BEETS_LIBRARY_DB | Path to the Beets library.db |
--music-root PATH | MUSIC_ROOT | Music root directory (where song paths are relative to) |
--bsn-db PATH | BSN_DB_PATH | Path to BeetstreamNext’s database (default: alongside library.db, or /config in Docker) |
--beets-config PATH | BSN_BEETS_CONFIG | Optional Beets config file, for path formats/plugins only (default: $BEETSDIR/config.yaml, if set and present) |
--host HOST[,HOST...] | BSN_HOST | Host(s) to listen on |
--port PORT | BSN_PORT | Port to listen on |
--threads N | BSN_THREADS | Waitress 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/nto 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_KEYis not included. Store it separately. -
To restore a backup made with a different key, see Changing the key.
-
Your Beets
library.dband 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 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.

Connections
Your existing app passwords and signed-in web sessions.

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.

Library
Every Library & Metadata related setting.

Audio
Every Audio & Jukebox related setting.

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.

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.


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

Shares
View, edit and revoke any active public share.

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

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.

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

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.

Configuration reference
BeetstreamNext settings can be set from various ways:
-
Explicit sources:
- via CLI flag
- via an environment variable
- in plugin mode: via Beets’
config.yaml, under abeetstreamnext:block (see Installation) - in standalone mode: via BeetstreamNext’s config YAML (see Installation)
-
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
homepage_github_link
Show the “View on GitHub” link on the public home page.
type: bool
default: True
homepage_docs_link
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
share_link_previews
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_proxyhas 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
hostentries - 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-streamconnection. If your proxy ignores theX-Accel-Bufferingheader, you should setproxy_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:
8081the public routes,8080also the Subsonic API and account pages,8082also the admin panel and control API. A request forshare.example.comwith a spoofedHost: admin.example.comheader still only gets public routes. -
The
admin-allowlistmiddleware 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_HOPSis the number of proxies in front of BeetstreamNext. Use2or 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_hostsandadmin_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, use1if 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 withcors_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
- addChatMessage
- changePassword
- createBookmark
- createInternetRadioStation
- createPlaylist
- createPodcastChannel
- createShare
- createUser
- deleteBookmark
- deleteInternetRadioStation
- deletePlaylist
- deletePodcastChannel
- deletePodcastEpisode
- deleteShare
- deleteUser
- download
- downloadPodcastEpisode
- findSonicPath
- getAlbum
- getAlbumInfo
- getAlbumInfo2
- getAlbumList
- getAlbumList2
- getArtist
- getArtistInfo
- getArtistInfo2
- getArtists
- getAvatar
- getBookmarks
- getCaptions
- getChatMessages
- getCoverArt
- getGenres
- getIndexes
- getInternetRadioStations
- getLicense
- getLyrics
- getLyricsBySongId
- getMusicDirectory
- getMusicFolders
- getNewestPodcasts
- getNowPlaying
- getOpenSubsonicExtensions
- getPlaylist
- getPlaylists
- getPlayQueue
- getPlayQueueByIndex
- getPodcastEpisode
- getPodcasts
- getRandomSongs
- getScanStatus
- getShares
- getSimilarSongs
- getSimilarSongs2
- getSong
- getSongsByGenre
- getSonicSimilarTracks
- getStarred
- getStarred2
- getTopSongs
- getTranscodeDecision
- getTranscodeStream
- getUser
- getUsers
- getVideoInfo
- getVideos
- hls
- jukeboxControl
- ping
- refreshPodcasts
- reportPlayback
- savePlayQueue
- savePlayQueueByIndex
- scrobble
- search
- search2
- search3
- setRating
- star
- startScan
- stream
- tokenInfo
- unstar
- updateInternetRadioStation
- updatePlaylist
- updateShare
- updateUser
Important
Following the reference Subsonic specification, error responses are returned with an HTTP 200 status. The real outcome is in the response body’s
statusfield ("ok"or"failed", with an errorcode/messageon failure).
Extensions
Alongside the base spec, BeetstreamNext implements all the OpenSubsonic extensions.
| Extension | Description |
|---|---|
apiKeyAuthentication | Authenticating with an API key alone, no password |
formPost | Accepting requests as application/x-www-form-urlencoded POST bodies |
getPodcastEpisode | Retrieving a single podcast episode’s metadata by ID |
indexBasedQueue | Setting/reading the play queue by index instead of only by ID |
playbackReport | Clients reporting their playback timeline back to the server |
songLyrics | Synchronized, multi-language lyrics, retrievable by song ID |
topSongsByArtistId | Retrieving an artist’s top songs by artist ID |
transcodeOffset | Starting a transcode from a given time offset |
transcoding | Clients making their own transcoding decisions and requesting transcoded streams directly |
sonicSimilarity | Acoustic similarity and playlist path-finding (see Library augmentation) |
Note
These extensions are always advertised by the server except for
sonicSimilaritywhich 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_authif 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).
| Feature | Service | What is sent |
|---|---|---|
| Metadata and album versions | MusicBrainz, Cover Art Archive | Release / artist identifiers and names |
| Artist and album info | Last.fm | Artist / album names and your lastfm_api_key |
| Scrobbling | ListenBrainz | What you play, when, and your ListenBrainz token |
| Scrobbling | Last.fm, Libre.fm | What you play, when, and your session key |
| Album ratings | Discogs | Release identifiers |
| Artist images | Deezer | Artist / album names |
| Radio stations | Radio Browser, the station’s own website, DuckDuckGo icons | Station names and domains (for favicons) |
| Podcasts | The feed and media hosts you subscribe to, and Podcast Index for search | Feed URLs and search terms |
| Notifications | Whatever Apprise service you configure | The notification content |
| Sonic similarity | Your own AudioMuse-AI instance | Track 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.
Share link previews
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.














