diff --git a/.gitignore b/.gitignore index fefe9b59e..e2e5954ec 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/README.md b/README.md index ffff4d500..1514fe791 100644 --- a/README.md +++ b/README.md @@ -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 [mStream iOS App](https://apps.apple.com/us/app/mstream-player/id1605378892) [mStream Android App](https://play.google.com/store/apps/details?id=com.nieratechinc.mstreamplayer&hl=en_US) diff --git a/docs/docker-compose/README.md b/docs/docker-compose/README.md new file mode 100644 index 000000000..9a1d7d50c --- /dev/null +++ b/docs/docker-compose/README.md @@ -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. diff --git a/docs/docker-compose/default/compose.yml b/docs/docker-compose/default/compose.yml new file mode 100644 index 000000000..6cecf7d76 --- /dev/null +++ b/docs/docker-compose/default/compose.yml @@ -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 diff --git a/docs/docker-compose/dev-everything/Dockerfile b/docs/docker-compose/dev-everything/Dockerfile new file mode 100644 index 000000000..520bb7426 --- /dev/null +++ b/docs/docker-compose/dev-everything/Dockerfile @@ -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/* diff --git a/docs/docker-compose/dev-everything/README.md b/docs/docker-compose/dev-everything/README.md new file mode 100644 index 000000000..537feda51 --- /dev/null +++ b/docs/docker-compose/dev-everything/README.md @@ -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://: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. diff --git a/docs/docker-compose/dev-everything/compose.yml b/docs/docker-compose/dev-everything/compose.yml new file mode 100644 index 000000000..a52b6df50 --- /dev/null +++ b/docs/docker-compose/dev-everything/compose.yml @@ -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 diff --git a/docs/docker-compose/dev-everything/mstream.json b/docs/docker-compose/dev-everything/mstream.json new file mode 100644 index 000000000..9e5912b49 --- /dev/null +++ b/docs/docker-compose/dev-everything/mstream.json @@ -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 +} diff --git a/docs/docker-compose/dev/README.md b/docs/docker-compose/dev/README.md new file mode 100644 index 000000000..5fd505e65 --- /dev/null +++ b/docs/docker-compose/dev/README.md @@ -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. diff --git a/docs/docker-compose/dev/compose.yml b/docs/docker-compose/dev/compose.yml new file mode 100644 index 000000000..d82086110 --- /dev/null +++ b/docs/docker-compose/dev/compose.yml @@ -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 diff --git a/docs/docker-compose/ssl-nginx/.env.example b/docs/docker-compose/ssl-nginx/.env.example new file mode 100644 index 000000000..115b6a434 --- /dev/null +++ b/docs/docker-compose/ssl-nginx/.env.example @@ -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 diff --git a/docs/docker-compose/ssl-nginx/README.md b/docs/docker-compose/ssl-nginx/README.md new file mode 100644 index 000000000..4f9517cc6 --- /dev/null +++ b/docs/docker-compose/ssl-nginx/README.md @@ -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 . +- 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. diff --git a/docs/docker-compose/ssl-nginx/compose.yml b/docs/docker-compose/ssl-nginx/compose.yml new file mode 100644 index 000000000..01ca2bd47 --- /dev/null +++ b/docs/docker-compose/ssl-nginx/compose.yml @@ -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: diff --git a/docs/docker-compose/ssl-nginx/nginx/Dockerfile b/docs/docker-compose/ssl-nginx/nginx/Dockerfile new file mode 100644 index 000000000..f6fd1bb91 --- /dev/null +++ b/docs/docker-compose/ssl-nginx/nginx/Dockerfile @@ -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;"] diff --git a/docs/docker-compose/ssl-nginx/nginx/default.conf.template b/docs/docker-compose/ssl-nginx/nginx/default.conf.template new file mode 100644 index 000000000..d38e9b3aa --- /dev/null +++ b/docs/docker-compose/ssl-nginx/nginx/default.conf.template @@ -0,0 +1,37 @@ +# This file is a template. entrypoint.sh substitutes two tokens at boot +# from .env: the base domain (DOMAIN env var) and the full hostname +# mStream is served at (MSTREAM_HOSTNAME env var). Placeholder strings +# are intentionally not written inline here — they'd be substituted in +# this very comment and obscure the docs. + +server { + listen 80; + server_name __DOMAIN__ *.__DOMAIN__; + rewrite ^ https://$host$request_uri? permanent; +} + +server { + listen 443 ssl; + server_name __MSTREAM_HOSTNAME__; + ssl_certificate /etc/ssl/certs/fullchain.pem; + ssl_certificate_key /etc/ssl/private/privkey.pem; + + location / { + proxy_pass http://mstream:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} + +# Catch-all for unknown hostnames under the wildcard — return 404 instead of +# defaulting to the first server block (which would expose mStream on any +# subdomain that resolves to this host). +server { + listen 443 ssl default_server; + server_name __DOMAIN__ *.__DOMAIN__; + ssl_certificate /etc/ssl/certs/fullchain.pem; + ssl_certificate_key /etc/ssl/private/privkey.pem; + location / { return 404; } +} diff --git a/docs/docker-compose/ssl-nginx/nginx/entrypoint.sh b/docs/docker-compose/ssl-nginx/nginx/entrypoint.sh new file mode 100644 index 000000000..aac8666e6 --- /dev/null +++ b/docs/docker-compose/ssl-nginx/nginx/entrypoint.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -e +ACME=/root/.acme.sh/acme.sh + +: "${DOMAIN:?DOMAIN env var required (e.g. example.com)}" +: "${MSTREAM_HOSTNAME:?MSTREAM_HOSTNAME env var required (e.g. music.example.com)}" +: "${CF_Token:?CF_Token env var required (Cloudflare API token, Zone:Read + Zone.DNS:Edit)}" +export CF_Token + +# Render the nginx config from the template +sed -e "s|__DOMAIN__|${DOMAIN}|g" \ + -e "s|__MSTREAM_HOSTNAME__|${MSTREAM_HOSTNAME}|g" \ + /etc/nginx/conf.d/default.conf.template \ + > /etc/nginx/conf.d/default.conf + +# Issue cert on first boot. Subsequent boots find the cert in the cert-out +# volume and skip — renewals are driven by the cron job acme.sh installs at +# install time (fires daily, only rotates within 30 days of expiry). +if [ ! -s /etc/ssl/certs/fullchain.pem ]; then + # If issuance fails (bad token, DNS misconfig) DO NOT exit — `restart: + # always` would loop us against Cloudflare + LE rate limits. Park instead + # so the container stays up but inert. To retry: fix .env, then + # `docker compose restart nginx`. + if ! "$ACME" --issue --dns dns_cf \ + -d "*.${DOMAIN}" -d "${DOMAIN}" --keylength ec-256; then + echo "[entrypoint] acme.sh --issue failed." + echo "[entrypoint] Fix credentials in .env, then: docker compose restart nginx" + echo "[entrypoint] Parking to avoid hammering Cloudflare + LE rate limits." + exec sleep infinity + fi + + install -d -m 0755 /etc/ssl/certs /etc/ssl/private + # reloadcmd is recorded by acme.sh and reused by the daily renewal cron. + # The PID guard makes first-boot install a no-op (nginx not running yet); + # the `exec nginx` below picks up the freshly-installed cert. On renewals + # nginx is already running, the guard passes, reload fires normally. + "$ACME" --install-cert -d "*.${DOMAIN}" --ecc \ + --key-file /etc/ssl/private/privkey.pem \ + --fullchain-file /etc/ssl/certs/fullchain.pem \ + --reloadcmd "[ -f /run/nginx.pid ] && nginx -s reload || true" +fi + +# Register a notify hook with acme.sh if configured in .env. Idempotent: +# --set-notify just rewrites the relevant lines in account.conf each call, +# so this handles both first boot and later credential rotations. `|| true` +# guards against a misconfigured hook taking nginx down — a broken notify +# channel shouldn't break TLS termination. +if [ -n "${ACME_NOTIFY_HOOK:-}" ]; then + "$ACME" --set-notify \ + --notify-hook "${ACME_NOTIFY_HOOK}" \ + --notify-level "${ACME_NOTIFY_LEVEL:-2}" \ + || true +fi + +# Renewal check on every startup, not just the daily cron — covers the case +# where the container restarts at a time that misses cron's window (host +# reboots, image updates, etc.). Idempotent: --cron only acts on certs +# within 30 days of expiry, no-ops otherwise. `|| true` keeps a transient +# API failure from taking nginx down; the existing cert serves until +# actual expiry. +"$ACME" --cron --home /root/.acme.sh || true + +service cron start +exec "$@" diff --git a/docs/docker-compose/with-mpd/README.md b/docs/docker-compose/with-mpd/README.md new file mode 100644 index 000000000..b4b35f349 --- /dev/null +++ b/docs/docker-compose/with-mpd/README.md @@ -0,0 +1,37 @@ +# mStream + MPD recipe + +mStream with server-side audio playback through a sidecar [MPD](https://www.musicpd.org/) daemon. mStream streams to browsers as usual *and* can play out of a sound device physically attached to the host — useful for a headless box wired to an amp. + +**Linux host only.** Audio passthrough mounts `/dev/snd`, which doesn't exist on Docker Desktop for Windows/macOS (containers run inside a VM with no host sound device). + +## How it works + +- MPD runs as a sidecar with `/dev/snd` passed through and the `audio` group added, playing to the host's ALSA device. +- mStream and MPD share a named volume (`mpd-socket`) mounted at `/run/mpd` in both containers. MPD listens on a Unix socket there; mStream connects to it. +- `MSTREAM_MPD_HOST=/run/mpd/socket` tells mStream where the socket is. A Unix socket (not TCP) is required because MPD 0.22+ only honours `file://` playback URIs from local socket clients. +- **No config override is needed to make mStream prefer MPD.** With `autoBootServerAudio` at its default (`false`), mStream deliberately prefers MPD as its server-audio backend — it detects the daemon by probing `MSTREAM_MPD_HOST` and boots it automatically. (See `src/api/server-playback.js` and `src/api/cli-audio/`.) + +## Setup + +```bash +cp -r docs/docker-compose/with-mpd ~/mstream-mpd +cd ~/mstream-mpd +mkdir config music +# drop some audio into ./music +docker compose up -d +``` + +Then trigger server playback from the mStream UI. Confirm the backend came up with: + +```bash +docker compose logs mstream | grep -i "cli-audio\|server-audio\|mpd" +``` + +A line like `started MPD as fallback audio player` means it connected. + +## Gotchas + +- **Socket permissions across containers.** MPD creates the socket in the shared volume; mStream (running as `PUID=1000`) has to be able to connect to it. If logs show the MPD probe failing with a permission error, the socket's owner/mode doesn't line up with mStream's UID. The simplest fix is to run MPD with the same `PUID`/`PGID` as mStream so the socket is owned consistently, or set MPD's socket permissions permissively in `mpd.conf`. This is the most common failure mode for this recipe — check it first if server audio never appears. +- **Startup race.** `depends_on` only waits for the MPD *container* to start, not for MPD to be *listening*. If mStream probes the socket before MPD is ready, server audio shows unavailable until the next probe — re-trigger detection from Admin → Server Audio (or just restart mStream) once MPD is up. It self-heals; it isn't a permanent failure. +- **One process owns the sound device.** ALSA gives exclusive access to a PCM device. If something else on the host (or another container) holds `/dev/snd`, MPD's output will fail to open. Stop the other consumer or point MPD at a different ALSA device in `mpd.conf`. +- **`auto_update no` is intentional.** mStream feeds MPD absolute `file://` URIs at play time, so MPD doesn't index the library itself — its own database stays empty by design. Your library browsing happens in mStream, not MPD. diff --git a/docs/docker-compose/with-mpd/compose.yml b/docs/docker-compose/with-mpd/compose.yml new file mode 100644 index 000000000..e49226067 --- /dev/null +++ b/docs/docker-compose/with-mpd/compose.yml @@ -0,0 +1,41 @@ +services: + mpd: + image: vimagick/mpd:latest + container_name: mstream-mpd + devices: + - /dev/snd:/dev/snd + group_add: + - audio + volumes: + - ./music:/music:ro + - ./mpd.conf:/etc/mpd.conf:ro + - mpd-socket:/run/mpd + restart: unless-stopped + + mstream: + image: lscr.io/linuxserver/mstream:latest + container_name: mstream + environment: + - PUID=1000 + - PGID=1000 + - TZ=Etc/UTC + # mStream's MPD adapter connects here. Unix socket is required for + # MPD's file:// playback (MPD 0.22+ restricts file URIs to local + # clients). The shared `mpd-socket` volume puts the socket in both + # containers at the same path. + - MSTREAM_MPD_HOST=/run/mpd/socket + volumes: + - ./config:/config + # Must mount at the SAME path as in the mpd service — mStream sends + # absolute paths over the wire (e.g. file:///music/foo.mp3) without + # any rewriting, so both containers must agree on /music. + - ./music:/music:ro + - mpd-socket:/run/mpd + ports: + - "3000:3000" + depends_on: + - mpd + restart: unless-stopped + +volumes: + mpd-socket: diff --git a/docs/docker-compose/with-mpd/mpd.conf b/docs/docker-compose/with-mpd/mpd.conf new file mode 100644 index 000000000..1fd803e8f --- /dev/null +++ b/docs/docker-compose/with-mpd/mpd.conf @@ -0,0 +1,23 @@ +# Minimal MPD config for the mStream server-audio sidecar. +# +# mStream feeds MPD absolute file:// URIs at play time, so MPD doesn't need +# to scan or index the library — `auto_update no` is intentional. + +music_directory "/music" +db_file "/var/lib/mpd/mpd.db" +log_file "/var/log/mpd/mpd.log" +pid_file "/run/mpd/mpd.pid" +state_file "/var/lib/mpd/mpd.state" + +# Unix socket only — mStream connects via the shared `mpd-socket` volume. +# MPD 0.22+ requires a local (socket) client for file:// URI playback. +bind_to_address "/run/mpd/socket" + +auto_update "no" +default_permissions "read,add,control,admin" + +audio_output { + type "alsa" + name "alsa-out" + mixer_type "software" +} diff --git a/docs/docker-compose/with-transmission/.env.example b/docs/docker-compose/with-transmission/.env.example new file mode 100644 index 000000000..ce74e3a92 --- /dev/null +++ b/docs/docker-compose/with-transmission/.env.example @@ -0,0 +1,6 @@ +# Copy to `.env` and rotate the password before first boot. +# These same credentials get entered into mStream's admin UI when you +# connect the torrent backend (see this recipe's README). + +TRANSMISSION_USER=mstream +TRANSMISSION_PASS=changeme diff --git a/docs/docker-compose/with-transmission/README.md b/docs/docker-compose/with-transmission/README.md new file mode 100644 index 000000000..5808d8e4a --- /dev/null +++ b/docs/docker-compose/with-transmission/README.md @@ -0,0 +1,88 @@ +# mStream + Transmission recipe + +mStream alongside a Transmission daemon, with completed downloads served straight back into mStream as a library. Browse + play your torrented music without copying files around. + +## Three clients, one recommendation + +mStream supports three torrent backends — **Transmission**, **qBittorrent**, and **Deluge**. This recipe uses Transmission, which is what we recommend for new setups: + +- **Path auto-detection is verified, not inferred.** Transmission exposes a `free-space` RPC call that mStream uses to probe candidate paths and confirm the daemon can actually see them — `verified` confidence even on an empty daemon. qBittorrent and Deluge have no equivalent: both fall back to a content-match probe (verified, but only if at least one existing torrent's content lines up with mStream's library) then known-paths prefix matching (`inferred` — usually correct, but a guess rather than a check). +- **Smallest resource footprint** of the three, especially noticeable on low-end NAS / SBC deployments. +- **Stable, well-documented RPC protocol** with a single auth model (Basic + CSRF). qBittorrent's session-cookie WebAPI v2 has more moving parts; Deluge's WebUI JSON-RPC is single-user-by-design. + +If you'd rather use qBittorrent or Deluge, the path-mapping shape in `compose.yml` carries over — only the daemon service definition and the RPC-whitelist handling change. + +## Setup + +```bash +cp -r docs/docker-compose/with-transmission ~/mstream-torrents +cd ~/mstream-torrents +cp .env.example .env +# edit .env: rotate TRANSMISSION_PASS to something real +mkdir downloads mstream-config transmission-config +docker compose up -d +``` + +First boot pulls both images, populates the linuxserver wrappers' `/config` dirs, and brings everything up. Watch with `docker compose logs -f`. + +## Connecting mStream to Transmission + +mStream needs the Transmission credentials to talk RPC. They're set in the admin UI rather than baked into a config file, because the same UI flow probes the daemon for its download paths during setup: + +1. Open mStream at `http://localhost:3000`, log in as admin (or create the admin account if this is first boot). +2. Admin → Torrents → set **Client: Transmission**. +3. Fill in the connection form: + - **Host:** `transmission` (the compose service name — Docker DNS resolves it on the internal network) + - **Port:** `9091` + - **Username:** value of `TRANSMISSION_USER` from your `.env` + - **Password:** value of `TRANSMISSION_PASS` + - **RPC path:** `/transmission/rpc` (default; the linuxserver image doesn't relocate it) + - **HTTPS:** off (RPC stays inside the compose network) +4. Save. mStream probes Transmission and reports the detected download path. If you see `/downloads/...` with a "verified" badge, the path mapping is wired up correctly — anything torrented from here on becomes browsable + playable in the mStream library. + +## Why the WHITELIST env vars matter + +This is the one piece that's easy to get wrong, so the recipe handles it for you. A fresh Transmission daemon ships with `rpc-whitelist-enabled: true` and a whitelist of just `127.0.0.1,::1`, plus a DNS-rebinding guard (`rpc-host-whitelist`). mStream runs in a *separate* container and reaches Transmission across the Docker network, so: + +- Its client IP is a Docker-subnet address (something in `172.x` / `10.x` / `192.168.x`), **not** `127.0.0.1` → the default IP whitelist would `403` every RPC call. +- It connects using the Host header `transmission` (a DNS name) → the rebinding guard would reject it. + +The `compose.yml` sets two linuxserver env vars to fix both: + +- `WHITELIST=127.0.0.1,10.*.*.*,172.*.*.*,192.168.*.*` — widens the IP whitelist to the private ranges Docker uses, while still excluding the public internet. +- `HOST_WHITELIST=transmission` — allows the service-name Host header. + +The real security boundary is the `USER` / `PASS` RPC authentication; the IP whitelist is defense-in-depth. If you change the mStream service name, update `HOST_WHITELIST` to match. + +## Layout + +| Path on host | Mount in transmission | Mount in mstream | Why | +|---|---|---|---| +| `./downloads/` | `/downloads` (rw) | `/music` (ro) | Same host source. Daemon writes, mStream reads. Path mapping `/downloads/X → /music/X` is auto-detected. | +| `./transmission-config/` | `/config` | — | linuxserver-style config tree for Transmission (settings.json, resume files, etc.) | +| `./mstream-config/` | — | `/config` | linuxserver-style config tree for mStream (DB, album art, logs) | + +`PUID`/`PGID` are `1000` in both services so files Transmission writes to `./downloads` are readable by mStream. Match them to the owner of your host directories (`id -u` / `id -g`). + +## Exposed ports + +| Port | Service | Bound to | Notes | +|---|---|---|---| +| 3000 | mStream WebUI | `0.0.0.0` | The thing you actually browse | +| 9091 | Transmission WebUI | `127.0.0.1` | Host-loopback only by default — the recipe ships with a placeholder password; rotate before exposing | +| 51413 (tcp+udp) | Transmission peer | `0.0.0.0` | Must be reachable from the public internet for peering. Port-forward at the router if you're behind NAT | + +## Adding torrents + +Three entry points after setup: + +- **mStream's web UI** has an "Add torrent" panel (Admin → Torrents) that talks to Transmission via mStream's API. New downloads land in the shared `./downloads/` dir and become library-visible. +- **Transmission's own WebUI** at `http://localhost:9091` works the same way — anything added here lands in the same dir and becomes visible to mStream on its next library scan. +- **Drop `.torrent` files** into `./transmission-config/watch/` and Transmission picks them up automatically (linuxserver image enables the watch dir by default). + +## Gotchas + +- **`PASS=changeme` is a placeholder.** The recipe binds Transmission's WebUI to `127.0.0.1` specifically because the default password isn't safe. Rotate it in `.env`, then optionally drop the `127.0.0.1:` prefix in `compose.yml` if you want the WebUI reachable on the LAN. +- **Peer port 51413** needs to be reachable from the public internet for peering to actually work. If you're behind NAT, port-forward it. Behind CGNAT, downloads will stall — that's a peer-discovery problem, not an mStream problem. +- **Library scans are mStream's, not Transmission's.** When a torrent completes, mStream sees the files on its next scan cycle (`scanOptions.scanInterval` in mStream's config; default 24h). For testing, kick off a manual rescan from the admin UI. +- **No `/dev/snd` here.** This recipe is streaming-only — no server-side audio. Stack with the [`with-mpd/`](../with-mpd/) recipe if you want both. diff --git a/docs/docker-compose/with-transmission/compose.yml b/docs/docker-compose/with-transmission/compose.yml new file mode 100644 index 000000000..0569b722b --- /dev/null +++ b/docs/docker-compose/with-transmission/compose.yml @@ -0,0 +1,59 @@ +services: + transmission: + image: lscr.io/linuxserver/transmission:latest + container_name: mstream-transmission + environment: + - PUID=1000 + - PGID=1000 + - TZ=Etc/UTC + # RPC + WebUI auth. Interpolated by Compose from .env at parse time. + - USER=${TRANSMISSION_USER} + - PASS=${TRANSMISSION_PASS} + # CRITICAL for cross-container RPC. A fresh Transmission only + # whitelists 127.0.0.1, so it would 403 mStream's RPC calls — mStream + # connects from a Docker-network IP (one of the private ranges below), + # not loopback. WHITELIST fills rpc-whitelist; HOST_WHITELIST fills + # rpc-host-whitelist (Transmission's DNS-rebinding guard, which would + # otherwise reject the `transmission` Host header). USER/PASS auth is + # the real security boundary; the IP whitelist stays narrowed to + # private ranges as defense-in-depth. + - WHITELIST=127.0.0.1,10.*.*.*,172.*.*.*,192.168.*.* + - HOST_WHITELIST=transmission + volumes: + - ./transmission-config:/config + # Completed downloads. The same host directory is mounted into the + # mstream container below as /music:ro — mStream's torrent + # integration auto-detects the mapping between the daemon's + # /downloads and its own /music view via Transmission's free-space + # RPC probe. + - ./downloads:/downloads + ports: + # WebUI bound to 127.0.0.1 so the placeholder password in .env + # isn't exposed to the LAN. Drop the host prefix to expose it + # (after rotating PASS to something real). + - "127.0.0.1:9091:9091" + # Peer port — must be reachable from the public internet for + # actual peering. Both TCP and UDP. + - "51413:51413" + - "51413:51413/udp" + restart: unless-stopped + + mstream: + image: lscr.io/linuxserver/mstream:latest + container_name: mstream + environment: + - PUID=1000 + - PGID=1000 + - TZ=Etc/UTC + volumes: + - ./mstream-config:/config + # Read-only view of Transmission's download dir. Same host source + # as transmission's /downloads above — mStream's torrent module + # resolves /downloads/ (daemon-reported) → /music/ + # (mStream-readable) automatically. + - ./downloads:/music:ro + ports: + - "3000:3000" + depends_on: + - transmission + restart: unless-stopped