Skip to content

Add Social Party DJ web app with Cloud Run deployment - #4

Draft
drtamar wants to merge 11 commits into
mainfrom
claude/fix-jetbrains-review-feedback
Draft

Add Social Party DJ web app with Cloud Run deployment#4
drtamar wants to merge 11 commits into
mainfrom
claude/fix-jetbrains-review-feedback

Conversation

@drtamar

@drtamar drtamar commented May 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add party_dj_server.py — Flask web app guests access by scanning a QR code: live now-playing display, skip voting, remix search, song suggestions queue
  • Add spotify-api/credentials.py — zero-dependency credential encryption (PBKDF2 + HMAC-SHA256, stdlib only)
  • Add Dockerfile + deploy-cloud.sh — one-command deploy to Google Cloud Run (project: tams-parties, domain: dj.elama.top)
  • Add .github/workflows/deploy.yml — auto-deploy to Cloud Run on push to main
  • Add PARTY_DJ_SERVER.md — full documentation
  • Add cross-platform setup scripts (Mac/Linux, Windows, Android/Termux)

To go live

  1. Get Spotify refresh token (run get_token_manual.py locally)
  2. Set GitHub secrets: GCP_SA_KEY, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN
  3. Merge this PR → GitHub Actions deploys automatically to https://dj.elama.top

Test plan

  • python spotify-api/scripts/party_dj_server.py starts on port 5000
  • Guest UI loads at http://localhost:5000
  • Now-playing shows current Spotify track
  • Skip vote increments and auto-skips at threshold
  • Song search returns results; suggest adds to queue
  • Cloud Run deploy completes after secrets are set

https://claude.ai/code/session_017UCH47E9HF6TBeygxRQy4R


Generated by Claude Code

claude added 11 commits May 27, 2026 19:13
- 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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +34 to +35
def _derive_key(password: str, salt: bytes) -> bytes:
return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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.

Suggested change
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:]

Comment on lines +67 to +80
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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Update _decrypt_bytes to use the separate encryption and MAC keys derived from _derive_keys.

Suggested change
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)))

Comment on lines +53 to +64
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(),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Update _encrypt_bytes to use the separate encryption and MAC keys derived from _derive_keys.

Suggested change
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(),
}

Comment on lines +119 to +134
@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})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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})

Comment on lines +87 to +98
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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)

Comment on lines +142 to +148
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)

Comment thread setup.sh
Comment on lines +41 to +43
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)"

Comment thread setup-android.sh
Comment on lines +47 to +48
sed -i "s/your_client_id_here/$CLIENT_ID/" "$ENV_FILE"
sed -i "s/your_client_secret_here/$CLIENT_SECRET/" "$ENV_FILE"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)"

Comment on lines +108 to +116
@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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Acquire the lock when reading the shared skip_votes and suggestions lists to prevent race conditions and ensure thread safety.

Suggested change
@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)

Comment thread Dockerfile
Comment on lines +18 to +23
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

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"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants