Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ save/logs/*
save/conf/*
!save/conf/README.md

save/db/*
!save/db/README.md

save/sync-cdn/*
!save/conf/README.md

Expand Down Expand Up @@ -75,4 +78,15 @@ save/waveform-cache-smoke/

# Generated API docs (built from docs/openapi.yaml via `npm run docs:api`;
# also built+deployed by .github/workflows/publish-api-docs.yml)
docs/api.html
docs/api.html

# Runtime artifacts from docs/docker-compose/ recipes (bind-mount targets,
# user secrets, deployment-local state). The compose files + Dockerfiles +
# READMEs are committed; everything users generate when running the
# recipes is not.
docs/docker-compose/*/config/
docs/docker-compose/*/mstream-config/
docs/docker-compose/*/music/
docs/docker-compose/*/downloads/
docs/docker-compose/*/transmission-config/
docs/docker-compose/*/.env
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ The demo site does not require you to sign in. mStream is publicly accessible by
* [Install From Source](docs/install.md)
* [AWS Cloud using Terraform](https://gitlab.com/SiliconTao-Systems/nova)

### Docker Compose recipes

Drop-in `compose.yml` recipes for common deployments — see the [cookbook README](docs/docker-compose/) for the full index and per-recipe notes.

* [**default/**](docs/docker-compose/default/) — streaming-only, single linuxserver/mstream container
* [**with-mpd/**](docs/docker-compose/with-mpd/) — adds server-side audio playback via a sidecar MPD container
* [**with-transmission/**](docs/docker-compose/with-transmission/) — mStream + Transmission, with completed downloads served back into the library automatically (Transmission recommended over qBittorrent / Deluge)
* [**ssl-nginx/**](docs/docker-compose/ssl-nginx/) — mStream behind nginx with a Let's Encrypt wildcard cert (acme.sh + Cloudflare DNS-01)
* [**dev/**](docs/docker-compose/dev/) — runs mStream from this repo's source with `node --watch` for hot reload
* [**dev-everything/**](docs/docker-compose/dev-everything/) — `dev/` with DLNA, Subsonic, rust-server-audio, and ALSA passthrough preconfigured

## Mobile Apps

[<img src="/webapp/assets/img/app-store-logo.png" alt="mStream iOS App" width="200" />](https://apps.apple.com/us/app/mstream-player/id1605378892) [<img src="/webapp/assets/img/play-store-logo.png" alt="mStream Android App" width="200" />](https://play.google.com/store/apps/details?id=com.nieratechinc.mstreamplayer&hl=en_US)
Expand Down
16 changes: 16 additions & 0 deletions docs/docker-compose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Docker Compose Cookbook

Drop-in `compose.yml` recipes for running mStream under Docker Compose. Most recipes are self-contained directories you can `cp -r` next to your music library and bring up with `docker compose up -d`. The `dev/` recipe is the exception — it stays in the repo.

## Recipes

- **[default/](default/)** — Streaming-only deployment. Single linuxserver/mstream container, no server-side audio.
- **[with-mpd/](with-mpd/)** — Streaming plus server-side audio playback via a sidecar MPD container. Requires a Linux host with a real audio device; Docker Desktop on Windows/macOS cannot pass `/dev/snd` through. See [with-mpd/README.md](with-mpd/README.md).
- **[with-transmission/](with-transmission/)** — mStream + a Transmission daemon, with completed downloads served back into the library automatically. mStream supports three torrent clients (Transmission, qBittorrent, Deluge) — see [with-transmission/README.md](with-transmission/README.md) for why we recommend Transmission.
- **[ssl-nginx/](ssl-nginx/)** — mStream behind an nginx reverse proxy with a Let's Encrypt wildcard cert, issued and auto-renewed via `acme.sh` + Cloudflare DNS-01. Requires a Cloudflare-managed domain.
- **[dev/](dev/)** — Runs mStream from this repo's source with `node --watch` for hot reload. Used in-place; see [dev/README.md](dev/README.md).
- **[dev-everything/](dev-everything/)** — Like `dev/` but with DLNA, Subsonic, the rust-server-audio binary, and ALSA passthrough all preconfigured. Linux host only; see [dev-everything/README.md](dev-everything/README.md).

For the deployable recipes, edit `PUID`/`PGID`, `TZ`, and the music/config volume paths in each `compose.yml` to match your host before bringing it up. `PUID`/`PGID` should match the owner of the mounted directories (`id -u` / `id -g` on the host).

The recipes track `:latest` for the linuxserver images (their conventional tag — they publish updates frequently). For a reproducible production deploy, pin to a specific image digest or version tag and bump it deliberately.
14 changes: 14 additions & 0 deletions docs/docker-compose/default/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
services:
mstream:
image: lscr.io/linuxserver/mstream:latest
container_name: mstream
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- ./config:/config
- ./music:/music:ro
ports:
- "3000:3000"
restart: unless-stopped
11 changes: 11 additions & 0 deletions docs/docker-compose/dev-everything/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM node:22-bookworm-slim

# libasound2 — runtime for the rust-server-audio binary (links cpal → ALSA)
# alsa-utils — `aplay -l` etc. for debugging device passthrough from inside the container
# mpv — CLI-fallback player exercised by src/api/cli-audio/mpv.js
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libasound2 \
alsa-utils \
mpv \
&& rm -rf /var/lib/apt/lists/*
39 changes: 39 additions & 0 deletions docs/docker-compose/dev-everything/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Dev-everything recipe

Like [`dev/`](../dev/) but with every server-side audio feature pre-enabled: DLNA, Subsonic, the bundled rust-server-audio binary, and ALSA passthrough. For exercising the full feature surface against live source.

## What's enabled

- **DLNA** — `same-port` mode on port 3000 with directory-style browse layout. SSDP discovery requires multicast, hence `network_mode: host` below.
- **Subsonic API** — `same-port` mode. Reachable at `http://<host>:3000/rest/...` or via any Subsonic client.
- **Rust server-audio binary** — `autoBootServerAudio: true`. mStream spawns `bin/rust-server-audio/rust-server-audio-linux-x64` at startup; the binary's HTTP control plane listens on 3333 internally.
- **ALSA passthrough** — `/dev/snd` mapped, `audio` group added, `libasound2` baked into the image. The rust binary plays through the host's default ALSA device.
- **mpv installed** — `src/api/cli-audio/mpv.js` can take over if the rust binary fails to spawn.

## Constraints

- **Linux host only.** `devices: /dev/snd` doesn't exist on Docker Desktop for Windows/macOS, and `network_mode: host` behaves very differently outside Linux.
- **Public-mode auth.** `users: {}` → no login required. Don't expose this recipe past localhost or a trusted LAN.

## Usage

From the repo root:

mkdir -p docs/docker-compose/dev-everything/music
docker compose -f docs/docker-compose/dev-everything/compose.yml up

Drop test tracks into `docs/docker-compose/dev-everything/music/` and trigger a scan from the mStream UI.

## Config persistence

`mstream.json` is bind-mounted RW. On first boot mStream adds two derived fields in place:

- `secret` — base64-encoded JWT signing key.
- `dlna.uuid` — stable renderer identifier across restarts.

To reset state, `docker compose down`, then either `git checkout mstream.json` or delete and restore from this repo.

## Gotchas inherited from `dev/`

- Anonymous `node_modules` volume persists across up/down — after `package.json` changes, `docker compose down -v` to force a clean reinstall.
- `node --watch` reloads on any source change under `/app`, which is the entire bind-mounted repo.
34 changes: 34 additions & 0 deletions docs/docker-compose/dev-everything/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
services:
mstream:
build: .
container_name: mstream-dev-everything
working_dir: /app
# Host networking so DLNA's SSDP multicast actually reaches the LAN.
# With bridge networking, mStream's TCP endpoints (mStream API, DLNA
# SOAP, Subsonic REST) all still work — but DLNA renderers won't
# auto-discover the server. Linux-only feature.
network_mode: host
devices:
- /dev/snd:/dev/snd
group_add:
- audio
volumes:
# Repo source. Paths are relative to this file —
# ../../.. resolves to the repo root.
- ../../..:/app
# Anonymous volume on node_modules so the container's `npm install`
# wins over whatever the host has.
- /app/node_modules
# Bind-mounted config. RW because mStream adds `secret` and
# `dlna.uuid` to it on first boot.
- ./mstream.json:/etc/mstream/mstream.json
# Sandbox music dir alongside this compose file. Drop tracks in
# ./music on the host; rescan from the mStream UI.
- ./music:/music:ro
environment:
- NODE_ENV=development
# See dev/compose.yml for the rationale on npm ci + skip-if-installed
# — short version: `npm install` modifies the bind-mounted lockfile,
# `npm ci` doesn't.
command: sh -c "[ -f /app/node_modules/.package-lock.json ] || npm ci --no-audit --no-fund; exec node --watch cli-boot-wrapper.js -j /etc/mstream/mstream.json"
restart: unless-stopped
21 changes: 21 additions & 0 deletions docs/docker-compose/dev-everything/mstream.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"port": 3000,
"address": "::",
"folders": {
"music": {
"root": "/music",
"type": "music"
}
},
"users": {},
"dlna": {
"mode": "same-port",
"name": "mStream Music (dev)",
"browse": "dirs"
},
"subsonic": {
"mode": "same-port"
},
"autoBootServerAudio": true,
"rustPlayerPort": 3333
}
25 changes: 25 additions & 0 deletions docs/docker-compose/dev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Dev recipe

Runs mStream from this repo's source with Node's built-in `--watch` for hot reload. Unlike the other recipes, this one is **tied to its position inside the repo** — `compose.yml` uses `../../..` to reach the repo root, so don't copy this directory elsewhere.

## Usage

From anywhere in the repo:

docker compose -f docs/docker-compose/dev/compose.yml up

Edit any file under `src/` and `node --watch` restarts the process within a second.

## Config

mStream reads `save/conf/default.json` at the repo root by default (see `cli-boot-wrapper.js`). That file is inside the bind-mounted source tree, so edits are picked up without rebuilding the container. To point at a different file:

docker compose -f docs/docker-compose/dev/compose.yml run --rm \
mstream node --watch cli-boot-wrapper.js -j /app/path/to/your.json

## Gotchas

- **No audio support baked in.** No `libasound2`, no `/dev/snd` passthrough. For dev work that needs server-side audio, either layer `apt-get install libasound2 mpv` into a child Dockerfile, or run the `with-mpd/` recipe in parallel and point this dev container at its MPD socket via `MSTREAM_MPD_HOST`.
- **No `/music` mount.** Mount your library by adding a volume to `compose.yml` and pointing `save/conf/default.json` at the in-container path.
- **Bumping deps requires resetting `node_modules`.** The anonymous volume persists across `docker compose up`/`down`; after a `package.json` change run `docker compose -f docs/docker-compose/dev/compose.yml down -v` so the next boot reinstalls cleanly.
- **`node --watch` over a bind mount from Windows/macOS** can occasionally miss events (the host's filesystem-change events have to traverse the Docker Desktop VM). If a save doesn't trigger a restart, touching the file again usually does.
27 changes: 27 additions & 0 deletions docs/docker-compose/dev/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
services:
mstream:
image: node:22-bookworm-slim
container_name: mstream-dev
working_dir: /app
volumes:
# Bind-mount the repo. Paths relative to this file —
# ../../.. resolves to the repo root.
- ../../..:/app
# Anonymous volume on node_modules so the container's `npm install`
# wins over whatever the host has (esp. wrong-platform native bins
# when developing from Windows or macOS).
- /app/node_modules
environment:
- NODE_ENV=development
# First boot: clean `npm ci` install into the anonymous node_modules
# volume, then run mStream with Node's built-in --watch. Subsequent
# boots skip the install entirely (the .package-lock.json marker in
# node_modules signals it's already populated). Using `npm ci` not
# `npm install` so the bind-mounted package-lock.json on the host
# never gets touched — `npm install` will silently rewrite peer/
# optional metadata when there's drift, polluting the host tree.
# `exec` so node replaces sh (cleaner signal handling on shutdown).
command: sh -c "[ -f /app/node_modules/.package-lock.json ] || npm ci --no-audit --no-fund; exec node --watch cli-boot-wrapper.js"
ports:
- "3000:3000"
restart: unless-stopped
55 changes: 55 additions & 0 deletions docs/docker-compose/ssl-nginx/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copy this file to `.env` and fill in your values before `docker compose up -d`.

# Cloudflare API token with Zone:Read + Zone.DNS:Edit on the zone for $DOMAIN.
# Create at https://dash.cloudflare.com/profile/api-tokens
CF_Token=

# Base domain you control via Cloudflare DNS.
# The issued cert covers both ${DOMAIN} and *.${DOMAIN}.
DOMAIN=example.com

# Full hostname mStream will be served at. Must be under ${DOMAIN}.
MSTREAM_HOSTNAME=music.example.com

# Email for acme.sh registration with Let's Encrypt (expiry notifications, etc.).
# Baked into the image at build time — change requires `docker compose build`.
ACME_EMAIL=you@example.com

# ── Renewal-notification hook (optional) ────────────────────────────────
# Leave ACME_NOTIFY_HOOK blank to disable. Set it to a provider name and
# fill in the matching credentials block below. acme.sh persists the
# config into /root/.acme.sh/account.conf (in the acme-state volume) on
# first boot, so renewals fired by cron use it automatically. To rotate:
# edit this file, then `docker compose restart nginx`.
#
# Notification level: 0=errors only, 1=skipped, 2=everything (default 2
# here so the success trail confirms the cron is alive — use 0 to quiet).
ACME_NOTIFY_HOOK=
ACME_NOTIFY_LEVEL=2

# Provider credentials — uncomment the block matching your hook.

# ntfy.sh — ACME_NOTIFY_HOOK=ntfy
#NTFY_URL=https://ntfy.sh/your-private-topic
#NTFY_USER=
#NTFY_PASSWORD=

# Telegram bot — ACME_NOTIFY_HOOK=telegram
#TELEGRAM_BOT_APITOKEN=
#TELEGRAM_BOT_CHATID=

# Discord webhook — ACME_NOTIFY_HOOK=discord
#DISCORD_WEBHOOK_URL=

# Generic SMTP — ACME_NOTIFY_HOOK=smtp
#SMTP_FROM=
#SMTP_TO=
#SMTP_HOST=
#SMTP_PORT=587
#SMTP_USERNAME=
#SMTP_PASSWORD=
#SMTP_SECURE=tls

# For other providers (mailgun, sendgrid, slack, pushover, gotify, opsgenie,
# etc.) see https://github.com/acmesh-official/acme.sh/wiki/notify and the
# scripts under https://github.com/acmesh-official/acme.sh/tree/master/notify
44 changes: 44 additions & 0 deletions docs/docker-compose/ssl-nginx/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# SSL nginx recipe

mStream behind an nginx reverse proxy with a Let's Encrypt wildcard cert, issued and auto-renewed by [acme.sh](https://github.com/acmesh-official/acme.sh) using the Cloudflare DNS-01 challenge.

## What you get

- **mStream container** — `lscr.io/linuxserver/mstream`, no published port. Only reachable inside the compose network.
- **nginx container** — built from `nginx/Dockerfile`. Terminates TLS on 443, redirects 80 → 443, and proxies `${MSTREAM_HOSTNAME}` to `mstream:3000` over the internal network.
- **Wildcard cert** for `${DOMAIN}` and `*.${DOMAIN}`, issued via Cloudflare DNS challenge — no port-80 round-trip needed during issuance, so this works even if the host isn't directly reachable from the public internet at issuance time.
- **Auto-renewal** via the cron job acme.sh installs in the container (fires daily; rotates within 30 days of expiry; reloads nginx in place).

## Prerequisites

- A domain managed by Cloudflare DNS.
- A Cloudflare API token with **Zone:Read** + **Zone.DNS:Edit** on the zone, created at <https://dash.cloudflare.com/profile/api-tokens>.
- Ports **80** and **443** reachable on the host.

## Setup

cp -r docs/docker-compose/ssl-nginx ~/mstream-ssl
cd ~/mstream-ssl
cp .env.example .env
# edit .env: CF_Token, DOMAIN, MSTREAM_HOSTNAME, ACME_EMAIL
mkdir music mstream-config
docker compose up -d --build

First boot: nginx container runs `acme.sh --issue` and pulls a fresh cert. Watch with `docker compose logs -f nginx` — should land in 30–60 seconds (DNS propagation).

## Failure modes

- **Bad Cloudflare token / wrong zone.** Entrypoint sees the issuance failure and *parks the container* (`exec sleep infinity`) rather than restart-looping. Without this, `restart: always` would hammer the Let's Encrypt and Cloudflare APIs until you got rate-limited (LE caps validation failures at 5/account/hour; CF locks the account aggressively). Fix `.env`, then `docker compose restart nginx`.
- **Cert renewal fails silently.** acme.sh's cron writes to its own log inside the container; `docker exec mstream-nginx cat /root/.acme.sh/acme.sh.log` to inspect. The previous cert keeps serving traffic until it actually expires.
- **mStream not reachable.** Check `docker compose ps`; if mstream is up but nginx returns 502, the issue is usually the music dir bind-mount (mStream errors out at boot if `./music` doesn't exist as a real directory).

## Customization knobs

- **`ACME_EMAIL` change** requires `docker compose build` — it's baked into the image at acme.sh install time.
- **Different cert paths.** acme.sh writes to `/etc/ssl/certs/fullchain.pem` and `/etc/ssl/private/privkey.pem` inside the container (the `cert-out` volume). Both nginx and acme.sh agree on these paths — change one, change both.
- **Adding more proxied services.** Add another `server { listen 443 ssl; ... }` block in `nginx/default.conf.template`. The wildcard cert already covers any subdomain under `${DOMAIN}`, so no cert re-issuance is needed.
- **HTTP-01 challenge instead of DNS-01.** Useful if you don't want a Cloudflare API token. Means exposing port 80 to the public internet for the challenge handshake; swap `--dns dns_cf` for `--standalone` (or `--webroot /usr/share/nginx/html` if you want acme.sh to share the running nginx). Out of scope for this recipe.

## Notes for the curious

The `ssl_certificate` paths point at `/etc/ssl/certs/fullchain.pem` and `/etc/ssl/private/privkey.pem` — both inside the `cert-out` named volume. The volume is the source of truth: blow away the volume (`docker compose down -v`) to force re-issuance on next boot. Useful for testing the issuance path; expensive against the Let's Encrypt staging cap if you do it repeatedly.
35 changes: 35 additions & 0 deletions docs/docker-compose/ssl-nginx/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
services:
nginx:
build:
context: ./nginx
args:
ACME_EMAIL: ${ACME_EMAIL}
container_name: mstream-nginx
restart: always
env_file: .env
ports:
- "80:80"
- "443:443"
volumes:
- acme-state:/root/.acme.sh # acme.sh account + renewal cron + cert source-of-truth
- cert-out:/etc/ssl # what nginx reads (fullchain.pem, privkey.pem)
depends_on:
- mstream

mstream:
image: lscr.io/linuxserver/mstream:latest
container_name: mstream
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- ./mstream-config:/config
- ./music:/music:ro
# No host-port publish — only nginx is exposed. nginx reaches mstream
# via the default compose network using the service name `mstream`.
restart: unless-stopped

volumes:
acme-state:
cert-out:
16 changes: 16 additions & 0 deletions docs/docker-compose/ssl-nginx/nginx/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM nginx:stable

RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates cron openssl \
&& rm -rf /var/lib/apt/lists/*

ARG ACME_EMAIL=admin@example.com
RUN curl -fsSL https://get.acme.sh | sh -s email=${ACME_EMAIL} \
&& /root/.acme.sh/acme.sh --set-default-ca --server letsencrypt

COPY default.conf.template /etc/nginx/conf.d/default.conf.template
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
Loading