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.
327 lines
12 KiB
Python
Executable File
327 lines
12 KiB
Python
Executable File
#!/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()
|