From 9d19c5d44380c32098ed023b4392f35e14709c57 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sun, 20 Sep 2026 12:44:11 -0400 Subject: [PATCH] Parameterize compose per instance and add Spotify credential refresh script Every clone can now override COMPOSE_PROJECT_NAME, CONTAINER_NAME, MPD_PORT, HTTP_PORT, MOPIDY_CONFIG and MUSIC_DIR via .env to run an isolated Mopidy instance (see .env.example). scripts/refresh-spotify-creds.py regenerates the librespot credentials blob for whichever instance the local .env points at. --- .env.example | 20 ++ .gitignore | 1 + docker-compose.yml | 12 +- scripts/refresh-spotify-creds.py | 326 +++++++++++++++++++++++++++++++ 4 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100755 scripts/refresh-spotify-creds.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c7d6dc7 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy to .env and adjust per instance. Give every clone a unique +# COMPOSE_PROJECT_NAME and a unique set of ports. + +# Namespaces the named volumes (mopidy-data, mopidy-cache). +COMPOSE_PROJECT_NAME=mopidy + +# Container name shown in `docker ps`. +CONTAINER_NAME=mopidy + +# Host ports for MPD/JSON-RPC and the HTTP API. +MPD_PORT=6600 +HTTP_PORT=6680 + +# This instance's config file and music library. +MOPIDY_CONFIG=./config/mopidy.conf +MUSIC_DIR=./music + +# Override the pip packages installed at startup (defaults to the +# mopidy-webhooks bundle plus the web clients). +#EXTRA_PIP_PACKAGES= diff --git a/.gitignore b/.gitignore index a673525..a1e1ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +.env config/mopidy.conf music diff --git a/docker-compose.yml b/docker-compose.yml index 3bc6d18..75189cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,19 +2,19 @@ services: mopidy: build: . image: mopidy:4 - container_name: mopidy + container_name: ${CONTAINER_NAME:-mopidy} restart: unless-stopped init: true environment: - EXTRA_PIP_PACKAGES: "https://code.lab.unbl.ink/secstate/mopidy-webhooks/archive/0.2.3.zip Mopidy-Muse mopidy-bandcamp mopidy-party mopidy-soundcloud mopidy-api-explorer setuptools<81" + EXTRA_PIP_PACKAGES: "${EXTRA_PIP_PACKAGES:-https://code.lab.unbl.ink/secstate/mopidy-webhooks/archive/0.2.3.zip Mopidy-Muse mopidy-bandcamp mopidy-party mopidy-soundcloud mopidy-api-explorer setuptools<81}" volumes: - - ./config/mopidy.conf:/etc/mopidy/mopidy.conf:ro - - ./music:/var/lib/mopidy/media:ro + - "${MOPIDY_CONFIG:-./config/mopidy.conf}:/etc/mopidy/mopidy.conf:ro" + - "${MUSIC_DIR:-./music}:/var/lib/mopidy/media:ro" - mopidy-data:/var/lib/mopidy - mopidy-cache:/var/cache/mopidy ports: - - "6602:6600" - - "6682:6680" + - "${MPD_PORT:-6600}:6600" + - "${HTTP_PORT:-6680}:6680" volumes: mopidy-data: diff --git a/scripts/refresh-spotify-creds.py b/scripts/refresh-spotify-creds.py new file mode 100755 index 0000000..a0593d5 --- /dev/null +++ b/scripts/refresh-spotify-creds.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +import argparse +import html +import http.server +import json +import re +import socket +import subprocess +import sys +import threading +import time +import urllib.request +from datetime import datetime +from pathlib import Path + +CACHE_DIR = "/var/lib/mopidy/.local/share/mopidy/spotify/credentials-cache" +CREDS_NAME = "credentials.json" +GET_CREDS = Path.home() / ".cargo" / "bin" / "get_creds" +TEST_TRACK = "spotify:track:4uLU6hMCjMI75M1A2tKUQC" +DEFAULT_TUNNEL = "powellc@192.168.40.186" +INSTALL_CMD = ( + "cargo install --git https://github.com/librespot-org/librespot " + "--no-default-features --features rustls-tls-native-roots --example get_creds" +) + +RPC_URL = "http://localhost:6680/mopidy/rpc" + + +def load_env(path): + env = {} + if not path.is_file(): + return env + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip().strip('"').strip("'") + return env + + +def docker(*args, check=True): + return subprocess.run(["docker", *args], check=check, capture_output=True, text=True) + + +def container_running(container): + result = docker("inspect", "-f", "{{.State.Running}}", container, check=False) + return result.returncode == 0 and result.stdout.strip() == "true" + + +def cache_owner(container): + result = docker("exec", container, "stat", "-c", "%u:%g", CACHE_DIR, check=False) + owner = result.stdout.strip() + return owner if result.returncode == 0 and owner else "" + + +def pick_port(preferred): + for port in range(preferred, preferred + 20): + with socket.socket() as sock: + try: + sock.bind(("0.0.0.0", port)) + return port + except OSError: + continue + raise RuntimeError("no free port") + + +class PageHandler(http.server.BaseHTTPRequestHandler): + state = {} + tunnel = DEFAULT_TUNNEL + + def do_GET(self): + if self.path not in ("/", "/index.html"): + self.send_error(404) + return + state = self.state + url = html.escape(state.get("url") or "", quote=True) + status = html.escape(state.get("status", "")) + detail = html.escape(state.get("detail", "")) + waiting_since = state.get("waiting_since") + tunnel = "" + if waiting_since and time.time() - waiting_since > 30: + tunnel = ( + "

Still waiting. Is the SSH tunnel up on your laptop?
" + f"ssh -N -L 8898:localhost:8898 {self.tunnel}

" + ) + button = f'

Authorize with Spotify

' if url else "" + body = f""" +Mopidy Spotify credentials + +
+

Mopidy Spotify credentials

+
    +
  1. Start the tunnel on your laptop: ssh -N -L 8898:localhost:8898 {self.tunnel}
  2. +
  3. Click the button and approve the login.
  4. +
+{button} +{tunnel} +

{status}

+

{detail}

+
""" + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Refresh", "2") + self.end_headers() + self.wfile.write(body.encode()) + + def log_message(self, *args): + pass + + +def start_server(port, state, tunnel): + handler = type("Handler", (PageHandler,), {"state": state, "tunnel": tunnel}) + server = http.server.ThreadingHTTPServer(("0.0.0.0", port), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +def start_get_creds(creds_dir): + creds_dir.mkdir(parents=True, exist_ok=True) + stale = creds_dir / CREDS_NAME + if stale.exists(): + stale.rename(creds_dir / f"{CREDS_NAME}.old-{datetime.now():%Y%m%d-%H%M%S}") + proc = subprocess.Popen( + [str(GET_CREDS), str(creds_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + pattern = re.compile(r"^Browse to: (\S+)\s*$") + url = None + deadline = time.time() + 30 + while time.time() < deadline: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + match = pattern.match(line.strip()) + if match: + url = match.group(1) + break + if url is None: + proc.kill() + raise RuntimeError("could not parse the authorization URL from get_creds") + threading.Thread(target=proc.stdout.read, daemon=True).start() + return proc, url + + +def wait_for_blob(path, proc, timeout): + deadline = time.time() + timeout + while time.time() < deadline: + if path.exists() and path.stat().st_size > 0: + return True + if proc is not None and proc.poll() is not None: + return path.exists() and path.stat().st_size > 0 + time.sleep(0.5) + return False + + +def backup_existing(container, backup_dir): + backup_dir.mkdir(parents=True, exist_ok=True) + target = backup_dir / f"credentials-{datetime.now():%Y%m%d-%H%M%S}.json" + result = docker("cp", f"{container}:{CACHE_DIR}/{CREDS_NAME}", str(target), check=False) + return target if result.returncode == 0 else None + + +def install_blob(container, blob): + owner = cache_owner(container) + docker("exec", container, "mkdir", "-p", CACHE_DIR) + docker("cp", str(blob), f"{container}:{CACHE_DIR}/{CREDS_NAME}") + if owner: + docker("exec", container, "chown", owner, f"{CACHE_DIR}/{CREDS_NAME}") + docker("exec", container, "chmod", "600", f"{CACHE_DIR}/{CREDS_NAME}") + + +def rpc(method, params=None, timeout=10): + payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}).encode() + request = urllib.request.Request(RPC_URL, data=payload, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.load(response).get("result") + + +def wait_for_mopidy(timeout=90): + deadline = time.time() + timeout + while time.time() < deadline: + try: + rpc("core.get_version", timeout=5) + return True + except Exception: + time.sleep(2) + return False + + +def verify_playback(container, keep_playing): + rpc("core.tracklist.clear") + rpc("core.tracklist.add", {"uris": [TEST_TRACK]}) + rpc("core.playback.play") + time.sleep(10) + state = rpc("core.playback.get_state") + logs = docker("logs", "--since", "90s", container, check=False) + combined = (logs.stdout or "") + (logs.stderr or "") + errors = [ + line + for line in combined.splitlines() + if "GStreamer error" in line or "track is not available" in line + ] + if not keep_playing: + rpc("core.playback.stop") + rpc("core.tracklist.clear") + return state, errors + + +def main(): + parser = argparse.ArgumentParser( + description="Regenerate the Spotify credentials blob used by mopidy-spotify.", + epilog=( + "Reads .env for CONTAINER_NAME, COMPOSE_PROJECT_NAME and HTTP_PORT. " + "The interactive login needs an SSH tunnel from the browser machine." + ), + ) + parser.add_argument("--env-file", type=Path, default=Path(".env"), help="compose env file (default: .env)") + parser.add_argument("--container", help="container to install into (default from .env)") + parser.add_argument("--rpc-url", help="Mopidy JSON-RPC URL (default from .env HTTP_PORT)") + parser.add_argument("--tunnel", default=DEFAULT_TUNNEL, help=f"ssh user@host for the tunnel (default: {DEFAULT_TUNNEL})") + parser.add_argument("--port", type=int, default=8901, help="port for the setup web page (default: 8901)") + parser.add_argument("--creds-dir", type=Path, default=Path.home() / "creds-new", help="where get_creds stores the new blob") + parser.add_argument("--from-file", type=Path, help="skip the Spotify login and install an existing blob") + parser.add_argument("--install-helper", action="store_true", help="build get_creds with cargo if it is missing") + parser.add_argument("--no-restart", action="store_true", help="install the blob but do not restart the container") + parser.add_argument("--no-verify", action="store_true", help="skip the playback verification") + parser.add_argument("--keep-playing", action="store_true", help="do not stop playback after verification") + parser.add_argument("--backup-dir", type=Path, default=Path.home() / "creds-backups", help="where to back up the current blob") + args = parser.parse_args() + + global RPC_URL + + env = load_env(args.env_file) + container = args.container or env.get("CONTAINER_NAME") + if not container: + project = env.get("COMPOSE_PROJECT_NAME") or Path.cwd().name + container = f"{project}-mopidy-1" + RPC_URL = args.rpc_url or f"http://localhost:{env.get('HTTP_PORT', '6680')}/mopidy/rpc" + page_host = args.tunnel.split("@")[-1] + + if not container_running(container): + sys.exit(f"error: container {container} is not running") + + print(f"target: container={container} rpc={RPC_URL}") + + server = None + proc = None + state = {"status": "starting", "detail": ""} + + try: + if args.from_file: + blob = args.from_file + if not blob.is_file(): + sys.exit(f"error: {blob} not found") + print(f"using existing blob: {blob}") + else: + if not GET_CREDS.exists(): + if args.install_helper: + print("building get_creds ...") + subprocess.run(INSTALL_CMD, shell=True, check=True) + else: + sys.exit(f"error: {GET_CREDS} not found; run:\n {INSTALL_CMD}\nor pass --install-helper") + proc, url = start_get_creds(args.creds_dir) + state.update(url=url, status="waiting for Spotify login", waiting_since=time.time()) + port = pick_port(args.port) + server = start_server(port, state, args.tunnel) + print(f"Open http://{page_host}:{port}/ on your laptop") + print(f"Tunnel: ssh -N -L 8898:localhost:8898 {args.tunnel}") + blob = args.creds_dir / CREDS_NAME + if not wait_for_blob(blob, proc, timeout=600): + state.update(status="timed out", detail="no credentials file was produced") + sys.exit("error: timed out waiting for the Spotify login") + print(f"login captured: {blob}") + state.update(status="login captured", detail=str(blob)) + + backup = backup_existing(container, args.backup_dir) + state.update(status="installing credentials", detail=f"backup: {backup}" if backup else "") + install_blob(container, blob) + print("credentials installed") + + if not args.no_restart: + state.update(status="restarting mopidy", detail="") + docker("restart", container) + if not wait_for_mopidy(): + state.update(status="mopidy did not come back up", detail="") + sys.exit("error: mopidy did not come back up") + print("mopidy restarted") + + if not args.no_verify: + state.update(status="verifying playback", detail="") + playback_state, errors = verify_playback(container, args.keep_playing) + if playback_state == "playing" and not errors: + state.update(status="done", detail="playback verified, no GStreamer errors") + print("OK: playback verified, no GStreamer errors") + else: + state.update(status="verification failed", detail=f"state={playback_state} errors={len(errors)}") + print(f"FAIL: state={playback_state}") + for line in errors[:5]: + print(f" {line}") + sys.exit(1) + else: + state.update(status="done", detail="credentials installed, verification skipped") + print("OK: credentials installed") + except KeyboardInterrupt: + print("\ninterrupted") + sys.exit(130) + finally: + if proc is not None and proc.poll() is None: + proc.terminate() + if server is not None: + time.sleep(8) + server.shutdown() + + +if __name__ == "__main__": + main()