Add Social Party DJ web app with Cloud Run deployment - #4
Conversation
- Set Python SDK to 3.8 to match project minimum requirements - Remove .idea exclude from module content root so IDE indexes its own config https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
- setup.sh (Mac/Linux) and setup.ps1 (Windows): one-command setup that installs deps, writes .env from credentials prompt, generates refresh token, and validates — so the skill works on any device with one run - misc.xml: align SDK name with project minimum (Python 3.8, not 3.11) - spotify-skill.iml: remove .idea exclude so IDE indexes its own config https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
One-command setup for Android via Termux — installs system deps (cairo, python, openssl), Python packages, credentials, and refresh token flow adapted for mobile (browser URL instead of auto-open). https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
Guests scan a QR code on the DJ phone, open in their browser, and can: - Vote to skip the current song (auto-skips at configurable threshold) - Search Spotify and add song suggestions directly to the queue - See what others have suggested Runs on the DJ phone via Termux over local WiFi — no app install needed. Votes reset automatically when the track changes. Usage: python party_dj_server.py --skip-votes 3 https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
- credentials.py: PBKDF2-HMAC-SHA256 key derivation + HMAC-SHA256 stream cipher with authentication tag. Zero external dependencies, works on all platforms including Android/Termux. - .env.encrypted: encrypted credentials (safe to commit) - party_dj_server.py: load credentials via credentials.py - requirements.txt: no extra crypto package needed Encrypt: python credentials.py encrypt Load: from credentials import load_credentials; load_credentials() Password via env var to skip prompt: DJ_PASSWORD=xxx python party_dj_server.py https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
- Full-screen blurred album art background (crossfades on track change) - Animated crossfade progress bar at bottom of screen - Remix toggle: flips search to find remixes/extended versions - Smooth progress ticker (updates every 500ms without API calls) - Vote progress bar with live count - Track name fades out/in on song change - Incoming suggestion queue with numbered list - Mobile-first, no external CSS/JS dependencies https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
- Dockerfile: Python 3.11-slim + gunicorn, cloud-ready - .dockerignore: minimal image (no docs, secrets, or dev files) - deploy-cloud.sh: one-command deploy with custom subdomain support Reads Spotify creds from .env or prompts, sets as Cloud Run env vars, maps custom domain, prints DNS records and Spotify redirect URI to add. - party_dj_server.py: credential loading now prefers env vars (Cloud Run) over local files, graceful fallback chain. Usage: ./deploy-cloud.sh --subdomain dj.yourdomain.com https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
Triggers on changes to spotify-api/, Dockerfile, or .dockerignore. Reads Spotify credentials from GitHub secrets. Deploys to tams-parties project at dj.elama.top. https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
Covers quick start, features, API endpoints, credential loading, Android/Termux setup, Cloud Run deployment, and project structure. https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
There was a problem hiding this comment.
Code Review
This pull request introduces a Social Party DJ web server along with deployment and setup scripts, a Dockerfile, and a custom credential encryption utility. The code review highlights several critical areas for improvement: resolving cryptographic key reuse in the encryption utility by deriving separate keys for encryption and MAC; addressing concurrency and thread-safety issues across Flask request threads and background workers by introducing a lock; improving error logging instead of silently swallowing exceptions; enhancing script robustness (such as handling quotes in the ".env" parser, avoiding sed delimiter issues, and removing hardcoded project IDs); and securing the Docker container by running as a non-root user.
| def _derive_key(password: str, salt: bytes) -> bytes: | ||
| return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS) |
There was a problem hiding this comment.
Using the same derived key for both encryption (keystream generation) and authentication (HMAC MAC) is a cryptographic bad practice (key reuse). We should derive separate keys for encryption and MAC by generating a 64-byte key from PBKDF2 and splitting it.
| def _derive_key(password: str, salt: bytes) -> bytes: | |
| return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS) | |
| def _derive_keys(password: str, salt: bytes) -> tuple[bytes, bytes]: | |
| derived = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS, 64) | |
| return derived[:32], derived[32:] |
| def _decrypt_bytes(payload: dict, password: str) -> bytes: | ||
| salt = bytes.fromhex(payload["salt"]) | ||
| nonce = bytes.fromhex(payload["nonce"]) | ||
| ciphertext = base64.b64decode(payload["ciphertext"]) | ||
| stored_mac = bytes.fromhex(payload["mac"]) | ||
|
|
||
| key = _derive_key(password, salt) | ||
|
|
||
| expected_mac = hmac.new(key, nonce + ciphertext, "sha256").digest() | ||
| if not hmac.compare_digest(stored_mac, expected_mac): | ||
| raise ValueError("Wrong password or file is corrupted.") | ||
|
|
||
| return _xor(ciphertext, _keystream(key, nonce, len(ciphertext))) | ||
|
|
There was a problem hiding this comment.
Update _decrypt_bytes to use the separate encryption and MAC keys derived from _derive_keys.
| def _decrypt_bytes(payload: dict, password: str) -> bytes: | |
| salt = bytes.fromhex(payload["salt"]) | |
| nonce = bytes.fromhex(payload["nonce"]) | |
| ciphertext = base64.b64decode(payload["ciphertext"]) | |
| stored_mac = bytes.fromhex(payload["mac"]) | |
| key = _derive_key(password, salt) | |
| expected_mac = hmac.new(key, nonce + ciphertext, "sha256").digest() | |
| if not hmac.compare_digest(stored_mac, expected_mac): | |
| raise ValueError("Wrong password or file is corrupted.") | |
| return _xor(ciphertext, _keystream(key, nonce, len(ciphertext))) | |
| def _decrypt_bytes(payload: dict, password: str) -> bytes: | |
| salt = bytes.fromhex(payload["salt"]) | |
| nonce = bytes.fromhex(payload["nonce"]) | |
| ciphertext = base64.b64decode(payload["ciphertext"]) | |
| stored_mac = bytes.fromhex(payload["mac"]) | |
| enc_key, mac_key = _derive_keys(password, salt) | |
| expected_mac = hmac.new(mac_key, nonce + ciphertext, "sha256").digest() | |
| if not hmac.compare_digest(stored_mac, expected_mac): | |
| raise ValueError("Wrong password or file is corrupted.") | |
| return _xor(ciphertext, _keystream(enc_key, nonce, len(ciphertext))) |
| def _encrypt_bytes(plaintext: bytes, password: str) -> dict: | ||
| salt = os.urandom(16) | ||
| nonce = os.urandom(12) | ||
| key = _derive_key(password, salt) | ||
| ciphertext = _xor(plaintext, _keystream(key, nonce, len(plaintext))) | ||
| mac = hmac.new(key, nonce + ciphertext, "sha256").digest() | ||
| return { | ||
| "salt": salt.hex(), | ||
| "nonce": nonce.hex(), | ||
| "ciphertext": base64.b64encode(ciphertext).decode(), | ||
| "mac": mac.hex(), | ||
| } |
There was a problem hiding this comment.
Update _encrypt_bytes to use the separate encryption and MAC keys derived from _derive_keys.
| def _encrypt_bytes(plaintext: bytes, password: str) -> dict: | |
| salt = os.urandom(16) | |
| nonce = os.urandom(12) | |
| key = _derive_key(password, salt) | |
| ciphertext = _xor(plaintext, _keystream(key, nonce, len(plaintext))) | |
| mac = hmac.new(key, nonce + ciphertext, "sha256").digest() | |
| return { | |
| "salt": salt.hex(), | |
| "nonce": nonce.hex(), | |
| "ciphertext": base64.b64encode(ciphertext).decode(), | |
| "mac": mac.hex(), | |
| } | |
| def _encrypt_bytes(plaintext: bytes, password: str) -> dict: | |
| salt = os.urandom(16) | |
| nonce = os.urandom(12) | |
| enc_key, mac_key = _derive_keys(password, salt) | |
| ciphertext = _xor(plaintext, _keystream(enc_key, nonce, len(plaintext))) | |
| mac = hmac.new(mac_key, nonce + ciphertext, "sha256").digest() | |
| return { | |
| "salt": salt.hex(), | |
| "nonce": nonce.hex(), | |
| "ciphertext": base64.b64encode(ciphertext).decode(), | |
| "mac": mac.hex(), | |
| } |
| @app.route("/api/skip", methods=["POST"]) | ||
| def api_skip(): | ||
| global skip_votes | ||
| ip = request.remote_addr | ||
| if ip in skip_votes: | ||
| return jsonify({"ok": False, "msg": "Already voted"}) | ||
| skip_votes.add(ip) | ||
| count = len(skip_votes) | ||
| if count >= SKIP_THRESHOLD: | ||
| try: | ||
| get_client().next_track() | ||
| skip_votes = set() | ||
| return jsonify({"ok": True, "skipped": True, "votes": count}) | ||
| except Exception as e: | ||
| return jsonify({"ok": False, "msg": str(e)}) | ||
| return jsonify({"ok": True, "skipped": False, "votes": count, "needed": SKIP_THRESHOLD}) |
There was a problem hiding this comment.
The skip voting logic has a race condition where multiple concurrent requests could pass the threshold check and trigger next_track() multiple times. We should use the lock and is_skipping flag to ensure the skip is only triggered once, and release the lock during the external API call to avoid blocking other requests.
@app.route("/api/skip", methods=["POST"])
def api_skip():
global skip_votes, is_skipping
ip = request.remote_addr
with lock:
if ip in skip_votes:
return jsonify({"ok": False, "msg": "Already voted"})
skip_votes.add(ip)
count = len(skip_votes)
if count >= SKIP_THRESHOLD:
if not is_skipping:
is_skipping = True
should_trigger_skip = True
else:
should_trigger_skip = False
else:
should_trigger_skip = False
if should_trigger_skip:
try:
get_client().next_track()
with lock:
skip_votes.clear()
return jsonify({"ok": True, "skipped": True, "votes": count})
except Exception as e:
app.logger.exception("Failed to skip track")
return jsonify({"ok": False, "msg": str(e)})
finally:
with lock:
is_skipping = False
return jsonify({"ok": True, "skipped": False, "votes": count, "needed": SKIP_THRESHOLD})| def track_watcher(): | ||
| global last_track_id, skip_votes | ||
| while True: | ||
| try: | ||
| track = current_track() | ||
| tid = track.get("id", "") | ||
| if tid and tid != last_track_id: | ||
| last_track_id = tid | ||
| skip_votes = set() | ||
| except Exception: | ||
| pass | ||
| time.sleep(4) |
There was a problem hiding this comment.
The background thread track_watcher modifies the shared skip_votes set without acquiring the lock. Additionally, it silently swallows all exceptions, which makes debugging authentication or network issues extremely difficult. We should acquire the lock when clearing the votes and log any exceptions.
| def track_watcher(): | |
| global last_track_id, skip_votes | |
| while True: | |
| try: | |
| track = current_track() | |
| tid = track.get("id", "") | |
| if tid and tid != last_track_id: | |
| last_track_id = tid | |
| skip_votes = set() | |
| except Exception: | |
| pass | |
| time.sleep(4) | |
| def track_watcher(): | |
| global last_track_id, skip_votes | |
| while True: | |
| try: | |
| track = current_track() | |
| tid = track.get("id", "") | |
| if tid and tid != last_track_id: | |
| with lock: | |
| last_track_id = tid | |
| skip_votes.clear() | |
| except Exception as e: | |
| app.logger.error(f"Error in track_watcher: {e}") | |
| time.sleep(4) |
| def _inject_env(text: str) -> None: | ||
| for line in text.splitlines(): | ||
| line = line.strip() | ||
| if not line or line.startswith("#") or "=" not in line: | ||
| continue | ||
| k, _, v = line.partition("=") | ||
| os.environ.setdefault(k.strip(), v.strip()) |
There was a problem hiding this comment.
The custom .env parser does not strip surrounding single or double quotes from environment variable values. If the .env file contains quoted values (e.g., SPOTIFY_CLIENT_ID="12345"), they will be loaded with literal quotes, causing authentication to fail. We should strip surrounding quotes if they exist.
| def _inject_env(text: str) -> None: | |
| for line in text.splitlines(): | |
| line = line.strip() | |
| if not line or line.startswith("#") or "=" not in line: | |
| continue | |
| k, _, v = line.partition("=") | |
| os.environ.setdefault(k.strip(), v.strip()) | |
| def _inject_env(text: str) -> None: | |
| for line in text.splitlines(): | |
| line = line.strip() | |
| if not line or line.startswith("#") or "=" not in line: | |
| continue | |
| k, _, v = line.partition("=") | |
| val = v.strip() | |
| if len(val) >= 2 and ( | |
| (val.startswith('"') and val.endswith('"')) or | |
| (val.startswith("'") and val.endswith("'")) | |
| ): | |
| val = val[1:-1] | |
| os.environ.setdefault(k.strip(), val) |
| sed -i.bak "s/your_client_id_here/$CLIENT_ID/" "$ENV_FILE" | ||
| sed -i.bak "s/your_client_secret_here/$CLIENT_SECRET/" "$ENV_FILE" | ||
| rm -f "$ENV_FILE.bak" |
There was a problem hiding this comment.
Using sed with / as a delimiter to replace placeholders with client secrets can fail if the secret contains / (which is common in base64/secrets). Since Python is already required and checked, we can use Python to perform the replacement safely.
| sed -i.bak "s/your_client_id_here/$CLIENT_ID/" "$ENV_FILE" | |
| sed -i.bak "s/your_client_secret_here/$CLIENT_SECRET/" "$ENV_FILE" | |
| rm -f "$ENV_FILE.bak" | |
| python3 -c "import sys; p = '$ENV_FILE'; c = open(p).read().replace('your_client_id_here', '$CLIENT_ID').replace('your_client_secret_here', '$CLIENT_SECRET'); open(p, 'w').write(c)" |
| sed -i "s/your_client_id_here/$CLIENT_ID/" "$ENV_FILE" | ||
| sed -i "s/your_client_secret_here/$CLIENT_SECRET/" "$ENV_FILE" |
There was a problem hiding this comment.
Using sed with / as a delimiter to replace placeholders with client secrets can fail if the secret contains / (which is common in base64/secrets). Since Python is already required and checked, we can use Python to perform the replacement safely.
| sed -i "s/your_client_id_here/$CLIENT_ID/" "$ENV_FILE" | |
| sed -i "s/your_client_secret_here/$CLIENT_SECRET/" "$ENV_FILE" | |
| python -c "import sys; p = '$ENV_FILE'; c = open(p).read().replace('your_client_id_here', '$CLIENT_ID').replace('your_client_secret_here', '$CLIENT_SECRET'); open(p, 'w').write(c)" |
| @app.route("/api/now") | ||
| def api_now(): | ||
| track = current_track() | ||
| track["skip_votes"] = len(skip_votes) | ||
| track["skip_threshold"] = SKIP_THRESHOLD | ||
| track["my_vote"] = request.remote_addr in skip_votes | ||
| track["suggestions"] = suggestions[-20:] | ||
| track["crossfade"] = CROSSFADE_SECONDS | ||
| return jsonify(track) |
There was a problem hiding this comment.
Acquire the lock when reading the shared skip_votes and suggestions lists to prevent race conditions and ensure thread safety.
| @app.route("/api/now") | |
| def api_now(): | |
| track = current_track() | |
| track["skip_votes"] = len(skip_votes) | |
| track["skip_threshold"] = SKIP_THRESHOLD | |
| track["my_vote"] = request.remote_addr in skip_votes | |
| track["suggestions"] = suggestions[-20:] | |
| track["crossfade"] = CROSSFADE_SECONDS | |
| return jsonify(track) | |
| @app.route("/api/now") | |
| def api_now(): | |
| track = current_track() | |
| with lock: | |
| track["skip_votes"] = len(skip_votes) | |
| track["skip_threshold"] = SKIP_THRESHOLD | |
| track["my_vote"] = request.remote_addr in skip_votes | |
| track["suggestions"] = suggestions[-20:] | |
| track["crossfade"] = CROSSFADE_SECONDS | |
| return jsonify(track) |
| WORKDIR /app/spotify-api/scripts | ||
| ENV PYTHONPATH=/app/spotify-api | ||
| ENV PORT=8080 | ||
|
|
||
| CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 4 --timeout 60 \ | ||
| "party_dj_server:app" |
There was a problem hiding this comment.
The Dockerfile does not specify a non-root user, meaning the application runs as root inside the container. Running containers as root is a security risk in production environments (like Cloud Run). We should create and use a non-root user.
RUN useradd -m appuser
USER appuser
WORKDIR /app/spotify-api/scripts
ENV PYTHONPATH=/app/spotify-api
ENV PORT=8080
CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 4 --timeout 60 \
"party_dj_server:app"
Summary
party_dj_server.py— Flask web app guests access by scanning a QR code: live now-playing display, skip voting, remix search, song suggestions queuespotify-api/credentials.py— zero-dependency credential encryption (PBKDF2 + HMAC-SHA256, stdlib only)Dockerfile+deploy-cloud.sh— one-command deploy to Google Cloud Run (project:tams-parties, domain:dj.elama.top).github/workflows/deploy.yml— auto-deploy to Cloud Run on push tomainPARTY_DJ_SERVER.md— full documentationTo go live
get_token_manual.pylocally)GCP_SA_KEY,SPOTIFY_CLIENT_ID,SPOTIFY_CLIENT_SECRET,SPOTIFY_REFRESH_TOKENhttps://dj.elama.topTest plan
python spotify-api/scripts/party_dj_server.pystarts on port 5000http://localhost:5000https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R
Generated by Claude Code