feat(live): add live.transquinnftw.com deployment surface with SSO /admin (reuse quinn-www/vip pattern) + basic player at /shows/live and light admin preview page for OBS produced HLS from the relay cast (input cast IP, hls.js player).

Wiring: enable HLS port in cast/infra mediamtx + ufw notes; add deploy:live case + help in run/deploy.sh; update live deploy script.

Ties the quinn.cast relay (on-demand DO) to the VIP shows live feature (fanout to live.transquinnftw.com ingest powers the player; /admin for SSO operator preview + light admin).
This commit is contained in:
Natalie 2026-06-28 15:59:00 -04:00
parent ec98112267
commit 0da0e1233c
12 changed files with 382 additions and 1 deletions

View file

@ -26,6 +26,13 @@ rtspAddress: :8554
webrtc: yes webrtc: yes
webrtcAddress: :8889 webrtcAddress: :8889
# HLS for easy preview (used by live admin /admin and /shows/live player).
# URL example: http://<droplet>:8888/hls/live/produced/index.m3u8
hls: yes
hlsAddress: :8888
hlsAllowOrigin: '*'
hlsTrustedProxies: []
# Control API (used by healthchecks and optionally controller for stats). # Control API (used by healthchecks and optionally controller for stats).
api: yes api: yes
apiAddress: :9997 apiAddress: :9997

View file

@ -66,6 +66,7 @@ ufw default allow outgoing || true
ufw allow 22/tcp || true ufw allow 22/tcp || true
ufw allow 8080/tcp || true # LLM controller UI (gate with passphrase; put real TLS/domain in front soon) ufw allow 8080/tcp || true # LLM controller UI (gate with passphrase; put real TLS/domain in front soon)
ufw allow 8890/udp || true # SRT ingest (public by design; hotel push targets this) ufw allow 8890/udp || true # SRT ingest (public by design; hotel push targets this)
ufw allow 8888/tcp || true # HLS preview (for live.transquinnftw.com/admin + shows; operator or proxied)
ufw allow 1935/tcp || true # RTMP (local produced from OBS; also optional public ingest) ufw allow 1935/tcp || true # RTMP (local produced from OBS; also optional public ingest)
ufw allow 4455/tcp || true # obs-websocket (internal to compose; do not expose publicly) ufw allow 4455/tcp || true # obs-websocket (internal to compose; do not expose publicly)
ufw allow 8554/tcp || true # rtsp (optional) ufw allow 8554/tcp || true # rtsp (optional)

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Deploy for live.transquinnftw.com (VIP live shows player + /admin preview).
# Light static surface. Run via ./run deploy:live or directly with --from-local.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
REMOTE="${REMOTE:-quinn-vps}"
WWW_ROOT="/var/www/live.transquinnftw.com/public"
NGINX_SITE="live.transquinnftw.com"
echo "==> Deploying live.transquinnftw.com static to ${REMOTE}"
# Sync the static assets (player + admin preview)
ssh "$REMOTE" "mkdir -p ${WWW_ROOT} ${WWW_ROOT}/admin ${WWW_ROOT}/shows"
rsync -a --delete \
--exclude '.git' \
"${SCRIPT_DIR}/public/" \
"${REMOTE}:${WWW_ROOT}/"
# Deploy nginx vhost (if present)
if [[ -f "${SCRIPT_DIR}/nginx/prod.conf" ]]; then
echo "==> Syncing nginx vhost"
ssh "$REMOTE" "mkdir -p /etc/nginx/sites-available"
scp "${SCRIPT_DIR}/nginx/prod.conf" "${REMOTE}:/etc/nginx/sites-available/${NGINX_SITE}"
ssh "$REMOTE" "ln -sf /etc/nginx/sites-available/${NGINX_SITE} /etc/nginx/sites-enabled/${NGINX_SITE}"
ssh "$REMOTE" "nginx -t && systemctl reload nginx || true"
fi
echo "==> Done. Test: curl -I https://live.transquinnftw.com/ and https://live.transquinnftw.com/admin (SSO)"
echo "==> Player demo: https://live.transquinnftw.com/shows/live (add ?src=... for manual HLS test)"

View file

@ -0,0 +1,108 @@
# live.transquinnftw.com - VIP live shows player + operator admin
#
# This domain powers the VIP live streaming feature (vip.transquinnftw.com/shows/live,list).
# Public: /shows/live (player for authorized VIP clients), /shows (list?).
# Operator: /admin (light admin + preview of the OBS produced stream from the active relay).
#
# The high-bitrate feed is produced on a quinn.cast DO relay droplet (hotel SRT thin -> DO OBS encode)
# and fanned out (including to rtmp://live.transquinnftw.com/app/<show-key>) so the platform ingest
# can serve it here.
#
# SSO: reuse the quinn-www / quinn-vip pattern.
# - /admin is edge-gated with auth_request to quinn.sso (operator only).
# - Public player uses client-side VIP token (like the vip portal) or show-specific access.
# - The admin preview can pull HLS from the relay's mediamtx (e.g. http://<cast-ip>:8888/hls/live/produced/index.m3u8)
# or from the platform ingest HLS once wired.
#
# CSP strict, private where needed, noindex for admin.
server {
listen 80;
listen [::]:80;
server_name live.transquinnftw.com;
return 301 https://live.transquinnftw.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name live.transquinnftw.com;
# SSL — shared SAN cert with transquinnftw.com
ssl_certificate /etc/letsencrypt/live/transquinnftw.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/transquinnftw.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/transquinnftw.com/chain.pem;
# Mozilla Intermediate
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSLLIVE:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
root /var/www/live.transquinnftw.com/public;
index index.html;
client_body_timeout 10s;
client_header_timeout 10s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://my.transquinnftw.com; media-src 'self' blob: http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Simple static for player (can be enhanced to SPA later)
location /shows {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri $uri/ /shows/index.html;
}
location = /shows/live {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files /shows/live/index.html /shows/index.html =404;
}
# SSO gate for /admin (exact reuse of quinn-www / quinn-vip pattern)
location = /_sso_verify {
internal;
proxy_pass http://127.0.0.1:3025/auth/validate;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Forwarded-Host $host;
}
location @sso_redirect {
return 302 https://sso.transquinnftw.com/login?redirect=https://live.transquinnftw.com$request_uri;
}
# /admin : light admin + OBS stream preview (SSO required)
# The page (static for now, or future SPA shell) can:
# - Input cast IP or relay ID (from active VIP show)
# - Preview the OBS output via HLS from the relay mediamtx (http://<ip>:8888/hls/live/produced/index.m3u8)
# or platform ingest HLS.
# - Light controls/status (future: call relay controller API for scenes, start/stop if exposed).
location ^~ /admin {
auth_request /_sso_verify;
error_page 401 = @sso_redirect;
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files /admin/index.html /index.html =404;
}
# SPA / static fallback for other paths (player etc.)
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri $uri/ /index.html;
}
access_log /var/log/nginx/live.transquinnftw.com.access.log;
error_log /var/log/nginx/live.transquinnftw.com.error.log;
}

View file

@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>live / admin — VIP shows (SSO)</title>
<style>
:root { --bg: #0a0a0f; --fg: #e8e0d0; --accent: #c9a26b; --card: #12121a; --border: #2a2a38; }
body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); }
header { padding: 12px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.brand { font-weight: 700; letter-spacing: -.02em; }
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
h1 { font-size: 1.1rem; margin: 0 0 12px; }
label { display: block; font-size: .75rem; opacity: .7; margin-bottom: 4px; }
input { width: 100%; padding: 8px 10px; background: #0a0a0f; border: 1px solid var(--border); border-radius: 6px; color: inherit; font-family: ui-monospace, monospace; }
button { padding: 8px 14px; background: var(--accent); color: #111; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
video { width: 100%; background: #000; border-radius: 8px; }
.status { font-size: .8rem; padding: 4px 8px; border-radius: 4px; background: #1a1a22; }
.note { font-size: .7rem; opacity: .6; }
.row { display: flex; gap: 8px; align-items: flex-end; margin-bottom: 12px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body>
<header>
<div class="brand">live.transquinnftw.com / admin</div>
<div class="note">SSO protected • light admin + OBS preview (VIP shows)</div>
</header>
<main>
<div class="grid">
<!-- Controls / Status -->
<div class="card">
<h1>Relay Preview</h1>
<div class="row">
<div style="flex:1">
<label>Cast droplet IP (from active relay / provision)</label>
<input id="castIp" placeholder="203.0.113.77" value="">
</div>
<button onclick="loadPreview()">Load / Refresh Preview</button>
</div>
<div style="margin: 12px 0;">
<div class="status" id="status">Enter cast IP and load preview. HLS from relay mediamtx (produced feed).</div>
</div>
<div>
<label>Preview (OBS produced output from the cast)</label>
<video id="preview" controls playsinline></video>
<div class="note">HLS URL: http://&lt;ip&gt;:8888/hls/live/produced/index.m3u8 (enable hls in cast mediamtx if needed)</div>
</div>
<div style="margin-top:12px; font-size:.75rem; opacity:.7;">
Light admin: use the relay controller UI on the cast (http://&lt;ip&gt;:8080) or the quinn.cast LLM chat for full control (scenes, start/stop, destinations including vip-live).
Status can be pulled from the relay controller /api/status when exposed.
</div>
</div>
<!-- Light admin notes / future -->
<div class="card">
<h1>Light Admin</h1>
<p style="font-size:.85rem; opacity:.8;">This is the operator view for the active VIP live show relay.</p>
<ul style="font-size:.8rem; line-height:1.5; opacity:.8;">
<li>Preview the clean OBS output (scenes + overlays + mix) before/while fanning to the VIP ingest.</li>
<li>Active relay details (cast IP, status) come from the VIP show booking or relay controller.</li>
<li>For full control (LLM commands, add "vip-live" destination with show key): use the relay controller on the cast or the broadcast API.</li>
<li>The fanout from this relay powers the player at /shows/live for authorized VIP clients.</li>
</ul>
<div class="note">SSO via quinn.sso (same as vip.transquinnftw.com/admin and transquinnftw.com protected routes).</div>
</div>
</div>
</main>
<script>
let hls = null;
const video = document.getElementById('preview');
const statusEl = document.getElementById('status');
function loadPreview() {
const ip = document.getElementById('castIp').value.trim();
if (!ip) {
statusEl.textContent = 'Enter a cast droplet public IP.';
return;
}
const hlsUrl = `http://${ip}:8888/hls/live/produced/index.m3u8`;
statusEl.textContent = `Loading preview from ${hlsUrl} ...`;
if (hls) {
hls.destroy();
hls = null;
}
if (video.canPlayType('application/vnd.apple.mpegurl')) {
// native HLS (Safari)
video.src = hlsUrl;
} else if (Hls.isSupported()) {
hls = new Hls({ enableWorker: true, lowLatencyMode: true });
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
statusEl.textContent = 'Preview live (HLS manifest parsed).';
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (e, data) => {
console.warn('HLS error', data);
statusEl.textContent = 'Preview error (check cast mediamtx + HLS enabled + firewall).';
});
} else {
statusEl.textContent = 'HLS not supported in this browser.';
return;
}
// basic health ping to the relay controller (optional, if reachable; CORS may block)
fetch(`http://${ip}:8080/health`, { mode: 'no-cors' }).catch(() => {});
}
// Keyboard hint
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement.tagName === 'BODY') {
e.preventDefault();
document.getElementById('castIp').focus();
}
});
// Auto-hint
setTimeout(() => {
const ip = document.getElementById('castIp');
if (!ip.value) ip.placeholder = 'e.g. 203.0.113.77 (the active cast from provision)';
}, 800);
</script>
</body>
</html>

View file

@ -0,0 +1,7 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>live.transquinnftw.com</title></head>
<body style="font-family:system-ui;background:#0a0a0f;color:#e8e0d0;padding:40px">
<h1>live.transquinnftw.com</h1>
<p>VIP live shows: <a href="/shows/live">/shows/live</a></p>
<p>Admin (SSO): <a href="/admin">/admin</a></p>
</body></html>

View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>VIP Shows</title>
<style>body{font-family:system-ui,sans-serif;background:#0a0a0f;color:#e8e0d0;padding:40px}</style>
</head>
<body>
<h1>VIP Shows</h1>
<p>Live: <a href="/shows/live">/shows/live</a> (player for active authorized show).</p>
<p>List and admin preview at <a href="/admin">/admin</a> (SSO operator only).</p>
<p>The live feed is produced via the quinn.cast relay (hotel thin SRT → DO OBS → fanout to platform ingest at live.transquinnftw.com and/or external RTMPs).</p>
</body>
</html>

View file

@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VIP Show — Live</title>
<style>
body { margin:0; background:#0a0a0f; color:#e8e0d0; font-family:system-ui,sans-serif; }
.player { max-width: 1280px; margin: 40px auto; padding: 0 20px; }
video { width:100%; background:#000; border-radius:8px; }
.meta { opacity:.7; font-size:.85rem; margin: 12px 0; }
</style>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body>
<div class="player">
<h1 style="font-size:1.4rem; margin-bottom:8px;">VIP Show — Live</h1>
<div class="meta">Authorized clients only. Stream from the active relay (high-bitrate from DO, thin contribution from performer location).</div>
<video id="player" controls playsinline autoplay></video>
<div class="meta" id="info">HLS source will be set by the show system or query param (?src=...).</div>
</div>
<script>
const video = document.getElementById('player');
const info = document.getElementById('info');
// In real: the show key or token determines the stream path on the ingest.
// For demo: support ?src=http://.../index.m3u8 or default to a produced preview if known.
const params = new URLSearchParams(location.search);
let src = params.get('src') || params.get('hls');
if (!src) {
// Fallback demo note (in real the shows backend injects the correct ingest HLS URL for this show)
info.textContent = 'No ?src= provided. In production the /shows/live page for an active show will embed the correct HLS from the platform ingest (fed by the relay fanout to rtmp://live.transquinnftw.com/...).';
// You can manually test by appending ?src=http://<cast-ip>:8888/hls/live/produced/index.m3u8
}
function load(srcUrl) {
info.textContent = `Loading ${srcUrl} ...`;
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = srcUrl;
} else if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: true });
hls.loadSource(srcUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
info.textContent = 'Live (manifest parsed).';
video.play().catch(() => {});
});
} else {
info.textContent = 'HLS playback not supported here.';
}
}
if (src) {
load(src);
}
// Allow manual override in the demo
window.setLiveSrc = (u) => load(u);
</script>
</body>
</html>

View file

@ -27,6 +27,7 @@ services:
- "8890:8890/udp" # SRT ingest from hotel (main path) - "8890:8890/udp" # SRT ingest from hotel (main path)
- "1935:1935" # RTMP (for local produced feed + optional ingest) - "1935:1935" # RTMP (for local produced feed + optional ingest)
- "8554:8554" # RTSP - "8554:8554" # RTSP
- "8888:8888" # HLS (for /admin preview and /shows/live player on live.transquinnftw.com)
- "8889:8889" # WebRTC (future) - "8889:8889" # WebRTC (future)
- "9997:9997" # API (controller can poll if needed) - "9997:9997" # API (controller can poll if needed)
volumes: volumes:

View file

@ -183,4 +183,4 @@ echo " 3. From laptop: ./run deploy:cast --from-local (will install nginx sit
echo " 4. After deploy: ssh root@... journalctl -u quinn-cast -f" echo " 4. After deploy: ssh root@... journalctl -u quinn-cast -f"
echo " 5. Open https://cast.transquinnftw.com/?p=<your-passphrase> from anywhere (or via mesh)." echo " 5. Open https://cast.transquinnftw.com/?p=<your-passphrase> from anywhere (or via mesh)."
echo "" echo ""
echo "Security: firewall only 22/tcp + 80/tcp + 443/tcp + 8890/udp . Use ufw or doctl." echo "Security: firewall only 22/tcp + 80/tcp + 443/tcp + 8890/udp + 8888/tcp (HLS preview) . Use ufw or doctl."

1
run
View file

@ -34,6 +34,7 @@
# ./run deploy:ai Deploy quinn.ai dashboard to VPS (--direct only) # ./run deploy:ai Deploy quinn.ai dashboard to VPS (--direct only)
# ./run deploy:ai-worker Deploy ai inference worker to black (--direct only) # ./run deploy:ai-worker Deploy ai inference worker to black (--direct only)
# ./run deploy:cast Deploy quinn.cast (broadcast relay) to dedicated droplet via provision-stream (--from-local only) # ./run deploy:cast Deploy quinn.cast (broadcast relay) to dedicated droplet via provision-stream (--from-local only)
# ./run deploy:live Deploy live.transquinnftw.com (VIP shows player + SSO /admin with OBS preview) (--from-local only)
# ./run deploy:att Deploy adulttherapytour.com + SEO bait to vps-0 (--from-local only) # ./run deploy:att Deploy adulttherapytour.com + SEO bait to vps-0 (--from-local only)
# ./run deploy:cocotte Deploy cocotte.maison (+ defensive cocottehouse.com via defensive-coms) to vps-0 (--from-local only) # ./run deploy:cocotte Deploy cocotte.maison (+ defensive cocottehouse.com via defensive-coms) to vps-0 (--from-local only)
# ./run deploy:sansonnet Deploy sansonnet.maison (+ defensive maisonsansonnet.com via defensive-coms) to vps-0 (--from-local only) # ./run deploy:sansonnet Deploy sansonnet.maison (+ defensive maisonsansonnet.com via defensive-coms) to vps-0 (--from-local only)

View file

@ -216,6 +216,17 @@ case "$COMMAND" in
fi fi
;; ;;
deploy:live)
if [[ "$FROM_LOCAL" == "true" ]]; then
echo "[direct] Deploying live.transquinnftw.com (VIP shows player + /admin)..."
bash "$ROOT_DIR/deployments/@domains/live.transquinnftw.com/deploy.sh" "$@"
else
echo "ERROR: No CI for live yet. Use --from-local." >&2
echo " ./run deploy:live --from-local" >&2
exit 1
fi
;;
deploy:api) deploy:api)
if [[ "$FROM_LOCAL" == "true" ]]; then if [[ "$FROM_LOCAL" == "true" ]]; then
echo "[direct] Deploying quinn.api to production..." echo "[direct] Deploying quinn.api to production..."
@ -294,6 +305,7 @@ case "$COMMAND" in
echo " ./run deploy:ai Deploy quinn.ai dashboard to VPS (--from-local only)" echo " ./run deploy:ai Deploy quinn.ai dashboard to VPS (--from-local only)"
echo " ./run deploy:ai-worker Deploy ai inference worker to black (--from-local only)" echo " ./run deploy:ai-worker Deploy ai inference worker to black (--from-local only)"
echo " ./run deploy:cast Deploy quinn.cast (broadcast relay) to dedicated droplet via provision-stream (--from-local only)" echo " ./run deploy:cast Deploy quinn.cast (broadcast relay) to dedicated droplet via provision-stream (--from-local only)"
echo " ./run deploy:live Deploy live.transquinnftw.com (VIP shows + /admin SSO preview) (--from-local only)"
echo " ./run deploy:api Deploy quinn.api data API to VPS (--from-local only)" echo " ./run deploy:api Deploy quinn.api data API to VPS (--from-local only)"
echo " ./run deploy:att Deploy adulttherapytour.com + SEO bait to vps-0 (--from-local only)" echo " ./run deploy:att Deploy adulttherapytour.com + SEO bait to vps-0 (--from-local only)"
echo " ./run deploy:cocotte Deploy cocotte.maison to vps-0 (--from-local only)" echo " ./run deploy:cocotte Deploy cocotte.maison to vps-0 (--from-local only)"