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.
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@ -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=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
.env
|
||||
config/mopidy.conf
|
||||
music
|
||||
|
||||
@ -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:
|
||||
|
||||
326
scripts/refresh-spotify-creds.py
Executable file
326
scripts/refresh-spotify-creds.py
Executable file
@ -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 = (
|
||||
"<p class=warn>Still waiting. Is the SSH tunnel up on your laptop?<br>"
|
||||
f"<code>ssh -N -L 8898:localhost:8898 {self.tunnel}</code></p>"
|
||||
)
|
||||
button = f'<p><a href="{url}">Authorize with Spotify</a></p>' if url else ""
|
||||
body = f"""<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Mopidy Spotify credentials</title>
|
||||
<style>
|
||||
body{{font-family:sans-serif;background:#121212;color:#eee;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}}
|
||||
main{{max-width:44rem;padding:2rem}}
|
||||
a{{display:inline-block;background:#1db954;color:#000;font-size:1.3rem;font-weight:bold;padding:.9rem 2rem;border-radius:999px;text-decoration:none}}
|
||||
code{{background:#222;padding:.2rem .4rem;border-radius:.3rem}}
|
||||
.warn{{color:#f5c518}}
|
||||
</style></head>
|
||||
<body><main>
|
||||
<h1>Mopidy Spotify credentials</h1>
|
||||
<ol>
|
||||
<li>Start the tunnel on your laptop: <code>ssh -N -L 8898:localhost:8898 {self.tunnel}</code></li>
|
||||
<li>Click the button and approve the login.</li>
|
||||
</ol>
|
||||
{button}
|
||||
{tunnel}
|
||||
<p>{status}</p>
|
||||
<p>{detail}</p>
|
||||
</main></body></html>"""
|
||||
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()
|
||||
Reference in New Issue
Block a user