# DEPLOY-LENOVO — Guía de despliegue del stack karaoke en el Lenovo del evento

> **Audiencia**: Diego, en el día del evento (2026-05-15).
> **Estado actual de origen** (Lenovo VM dev `zen-admin-oclaw` / 192.168.1.182): stack 100% operativo verificado por QA el 2026-05-14 21:30. Edge `https://player.zensitpro.pro` operativo.
> **Modo de copiado**: este Lenovo desplegado es **distinto** al de la VM dev. Hay que clonar el repo + datos al portátil físico del evento.

---

## 0. Pre-flight (5 min antes de copiar nada)

### 0.1 Tener anotado de la VM dev (origen)

| Cosa | Valor verificado |
|---|---|
| Credenciales admin | user `diego` / pass `D13g0$$2026` (defaults hardcoded; admin/.env NO existe) |
| Puerto player | 8080 (npx serve) |
| Puerto mic-server | 8081 (WebSocket Python) |
| Puerto admin | 8082 (FastAPI con Basic Auth) |
| Default sink necesario | NO debe ser `karaoke_mic` (sería loopback fantasma) |
| Total canciones | 44 con MP3 karaoke / 38 con LRC |
| Tamaño total a copiar | songs/ 143M + songs-karaoke/ 157M + lyrics/ 172K ~ **300 MB** |

### 0.2 Antes de tocar el Lenovo del evento

```bash
# Comprobar espacio en /home
df -h /home   # Necesitas mínimo 1 GB libre

# Comprobar tarjeta de red activa
ip -4 addr show | grep "inet 192.168"  # Debe haber al menos una IP LAN
```

Si NO hay red 192.168.x.x: **NO CONTINÚES**. Conecta el cable Ethernet o WiFi al router de evento ANTES de arrancar.

---

## 1. Pre-requisitos en el Lenovo del evento

Ejecuta este bloque tal cual:

```bash
# Pre-requisitos APT
sudo apt update
sudo apt install -y python3 python3-pip python3-venv ffmpeg \
  pulseaudio-utils nodejs npm git curl

# Verificar
python3 --version   # 3.10+ requerido (3.12 ideal)
ffmpeg -version | head -1
node -v             # 18+ requerido (24 ideal)
pactl info | head -3
```

Si `pactl info` falla → PipeWire o PulseAudio no está corriendo. En Ubuntu/Debian moderno:

```bash
systemctl --user status pipewire pipewire-pulse wireplumber
# Si están parados: 
systemctl --user start pipewire pipewire-pulse wireplumber
```

---

## 2. Obtener el código y datos

### Opción A — git clone (si el Lenovo tiene acceso a Forgejo)

```bash
mkdir -p ~/projects/active
cd ~/projects/active
# Si tienes token Forgejo ya configurado:
git clone https://git.zensitpro.pro/diego/lan-media.git
# Si no, obtener token con tuipass desde otra máquina:
#   tuipass get infra/forgejo-api-token-zgd
# y usar:
#   git clone https://diego:<TOKEN>@git.zensitpro.pro/diego/lan-media.git
cd lan-media
```

### Opción B — rsync desde la VM dev (más rápido y seguro)

Desde el Lenovo del evento, asumiendo SSH a la VM dev por Tailscale o LAN:

```bash
mkdir -p ~/projects/active
rsync -av --exclude='.git' --exclude='__pycache__' --exclude='node_modules' \
  --exclude='reference-mp3_karaoke' \
  zen-admin@192.168.1.182:/home/zen-admin/projects/active/lan-media/ \
  ~/projects/active/lan-media/
cd ~/projects/active/lan-media
```

### Opción C — pendrive (si no hay red entre máquinas)

En la VM dev:

```bash
cd ~/projects/active
tar --exclude='.git' --exclude='__pycache__' --exclude='reference-mp3_karaoke' \
  -czf /tmp/lan-media.tgz lan-media/
# Copiar /tmp/lan-media.tgz al USB
```

En el Lenovo del evento:

```bash
mkdir -p ~/projects/active
tar -xzf /media/<USB>/lan-media.tgz -C ~/projects/active
cd ~/projects/active/lan-media
```

---

## 3. Instalar dependencias

```bash
cd ~/projects/active/lan-media

# 3.1 — Dependencias Python admin
pip3 install --break-system-packages -r requirements-admin.txt

# 3.2 — Dependencias Python mic-server + scripts de letras
pip3 install --break-system-packages websockets syncedlyrics yt-dlp

# Si --break-system-packages molesta: usa venv
#   python3 -m venv .venv && source .venv/bin/activate && pip install ...

# 3.3 — Verificar
python3 -c "import fastapi, uvicorn, websockets, pydantic; print('OK admin')"
python3 -c "import syncedlyrics; print('OK lyrics')"
yt-dlp --version
```

---

## 4. Configurar audio (paso CRÍTICO — no saltarlo)

> Sin esto el sonido NO sale por HDMI o por los altavoces externos. Si lo hago mal, el evento no suena.

### 4.1 Listar sinks reales (hardware)

```bash
pactl list sinks short
```

Resultado esperado: ves al menos un sink **físico** (HDMI, analógico, USB, etc.). Ejemplo:

```
40  alsa_output.pci-0000_00_1f.3.analog-stereo  PipeWire  ...  RUNNING
41  alsa_output.pci-0000_00_03.0.hdmi-stereo    PipeWire  ...  IDLE
```

Anota el nombre del sink que va al **proyector / TV** (típicamente HDMI). Lo llamaremos `HDMI_SINK`.

### 4.2 Marcar el HDMI como default ANTES de crear el virtual

```bash
HDMI_SINK="alsa_output.pci-0000_00_03.0.hdmi-stereo"  # cambia por el tuyo
pactl set-default-sink "$HDMI_SINK"
pactl get-default-sink   # Debe imprimir $HDMI_SINK
```

### 4.3 Crear el sink virtual del karaoke (mic)

```bash
bash scripts/setup-mic-sink.sh
```

Esto:
- Crea `null-sink karaoke_mic` para recibir el audio del cantante
- Crea un **loopback** desde `karaoke_mic.monitor` → `@DEFAULT_SINK@` (que ahora es HDMI)
- Resultado: voz del cantante se mezcla con la música y sale por TV

### 4.4 Verificar que el default sigue siendo HDMI (NO karaoke_mic)

```bash
pactl get-default-sink
# Debe imprimir el HDMI, NO 'karaoke_mic'
```

**Si imprime `karaoke_mic`** → MAL. Vuelve a:

```bash
pactl set-default-sink "$HDMI_SINK"
```

### 4.5 Test rápido de audio

```bash
# Reproduce un beep por el sink default
paplay /usr/share/sounds/freedesktop/stereo/bell.oga
# Debes oírlo en el TV/altavoces
```

Si no suena: revisar volumen TV, volumen sistema (`pactl set-sink-volume @DEFAULT_SINK@ 80%`), cable HDMI conectado.

---

## 5. Arrancar el stack

```bash
cd ~/projects/active/lan-media
bash scripts/start-event.sh
```

El script:
1. Reconstruye `player/playlist.json` con las canciones presentes
2. Si no existe `karaoke_mic`, llama a setup-mic-sink.sh (ya hecho en paso 4)
3. Arranca `mic-server.py` en :8081 (background)
4. Arranca `npx serve` en :8080 (background — descarga `serve` la primera vez, ~30s)
5. Arranca `admin/server.py` en :8082 (background)
6. Imprime las URLs

**No cierres este terminal** — Ctrl+C lo detiene todo.

### 5.1 Verificar que arrancó OK

En otra terminal:

```bash
cd ~/projects/active/lan-media

# La IP LAN de tu Lenovo
LAN_IP=$(ip -4 addr show | grep -oE 'inet 192\.168\.[0-9]+\.[0-9]+' | head -1 | awk '{print $2}')
echo "LAN IP: $LAN_IP"

# Smoke test
curl -sS -o /dev/null -w "Player: HTTP %{http_code}\n"  http://$LAN_IP:8080/player/
curl -sS -o /dev/null -w "Admin no-auth: HTTP %{http_code}\n"  http://$LAN_IP:8082/api/status     # debe ser 401
curl -sS -o /dev/null -w "Admin con auth: HTTP %{http_code}\n" -u diego:'D13g0$$2026' http://$LAN_IP:8082/api/status   # debe ser 200

# WebSocket mic
python3 - << EOF
import asyncio, websockets
async def t():
    async with websockets.connect("ws://$LAN_IP:8081/?name=test") as ws:
        await ws.send(b"\x00" * 64)
        print("WSS mic OK")
asyncio.run(t())
EOF
```

Todos deben pasar.

---

## 6. (Opcional) Acceso público vía Tailscale Funnel o CF Tunnel

> **Si NO necesitas exponer fuera del WiFi del evento, salta este paso** — más simple es más fiable.

### Tailscale Funnel (la más sencilla si Tailscale ya está en este Lenovo)

Requiere `tailscale` con Funnel habilitado en tu tailnet.

```bash
# Comprobar Tailscale
tailscale status | head -3

# Habilitar Funnel apuntando al puerto del player
sudo tailscale serve --bg --https=443 http://localhost:8080
sudo tailscale funnel --bg 443 on

# Ver URL pública asignada
tailscale serve status
```

Tu URL pública será `https://<machine-name>.<tailnet>.ts.net/` mientras Funnel esté activo.

**Limitación**: Funnel solo expone un puerto. Si quieres player + admin + mic-ws públicos, necesitas un Caddy local que multiplexe:

```bash
# /etc/caddy/Caddyfile minimal local — opcional
:80 {
  handle /player/* { reverse_proxy localhost:8080 }
  handle /admin/*  { reverse_proxy localhost:8082 }
  handle /mic-ws*  { reverse_proxy localhost:8081 }
  handle /api/*    { reverse_proxy localhost:8082 }
}
# Y luego: tailscale serve --https=443 http://localhost:80
```

### Cloudflare Tunnel (alternativa)

```bash
cloudflared tunnel login                  # abre browser
cloudflared tunnel create karaoke-evento
cloudflared tunnel route dns karaoke-evento mi-evento.zensitpro.pro
# Editar ~/.cloudflared/config.yml con la ruta a localhost:80
sudo cloudflared service install
```

### Caddy edge `player.zensitpro.pro` (ruta YA configurada hoy)

> Solo funciona si el Lenovo está en la red 192.168.1.x (router donde está el LXC 168 Caddy edge).

El Caddy edge en 192.168.1.59 ya enruta `player.zensitpro.pro` a `192.168.1.182:8080`. Si tu Lenovo del evento tiene una IP **distinta** a 192.168.1.182, hay que:

a) Cambiar la IP del Lenovo a 192.168.1.182 (más fácil — desconecta la VM dev primero), o
b) Editar el Caddyfile del edge LXC 168 para apuntar a la nueva IP.

**Recomendación**: si vas a usar `player.zensitpro.pro`, **deja la VM dev apagada** y asigna IP 192.168.1.182 al Lenovo del evento (DHCP reservation en Livebox o IP estática local).

---

## 6.b WiFi AP del Lenovo (opcional pero recomendado)

:::tip
**Si ya tienes Tailscale Funnel funcionando (`https://zen-admin-oclaw.tailc9332.ts.net/karaoke` o equivalente) — NO montes WiFi AP.**
La ruta HTTPS via Funnel resuelve todo (incluido `getUserMedia` para el mic), y no fuerza a los cantantes a cambiar de red.
La WiFi AP es HTTP plano y los móviles modernos **bloquean `getUserMedia` fuera de localhost/HTTPS** — el botón de mic no funcionará. Detalles abajo en la sección "CRÍTICO: HTTPS y mic en móvil".
Monta WiFi AP **solo si no tienes Funnel disponible** y aceptas que el mic puede no funcionar.
:::

Escenario: el Lenovo está conectado a Internet vía **tethering del móvil de Diego** (cable USB o WiFi al móvil), y queremos que el **propio Lenovo emita una red WiFi local** (modo AP) a la que los móviles de los cantantes se conectan sin tener que cada uno tetherar a Diego.

> Requisito: la WiFi card del Lenovo debe soportar modo AP **a la vez** que cliente (depende del chip + driver). La mayoría de Intel modernas (AX200/AX210) sí lo soportan; las viejas Atheros suelen también; algunas Realtek antiguas no.

### Paso 0 — Detectar si la WiFi card soporta AP

```bash
# Lista de modos que soporta la radio
iw list | grep -A 10 "Supported interface modes"
```

Busca **`* AP`** en la salida. Si no aparece → la tarjeta no soporta AP, salta a la sección "Sin soporte AP" abajo.

Comprobar también si soporta múltiples interfaces simultáneas (cliente + AP):

```bash
iw list | grep -A 6 "valid interface combinations"
```

Busca una línea como `#{ managed, AP } <= 2` — significa que puedes tener cliente managed + AP a la vez.

Si solo aparece `#{ managed } <= 1` o similar sin AP combinado → tendrás que elegir: o eres cliente WiFi, o eres AP. Si Internet entra por **USB tethering del móvil** (no por WiFi), esto NO importa — la WiFi puede dedicarse 100% a AP. Confirma:

```bash
ip route | grep default        # Default gateway debe ir por enxX (USB) o ethX, no wlanX
```

### Paso 1 — Anotar nombre de la interfaz WiFi

```bash
nmcli device status | grep wifi
# Ejemplo: wlp3s0   wifi   connected   Mi-WiFi
```

Anota el nombre (lo llamaremos `WLAN_DEV`, p.ej. `wlp3s0`).

### Paso 2 — Crear el AP

Elige UNO de estos métodos. Recomendado para fiabilidad: **Método C (nmcli)**.

#### Método A — GNOME Settings (UI, más simple)

1. Abrir `Settings` → `WiFi`
2. Menú hamburguesa (esquina superior derecha) → `Turn On Wi-Fi Hotspot…`
3. SSID: `MD-KARAOKE` · Password: `karaoke2026` (mínimo 8 chars)
4. Pulsar `Turn On`

Limitación: GNOME a veces fuerza modo `Ad-Hoc` en vez de Infraestructura/AP si el driver lo prefiere — los móviles modernos rechazan Ad-Hoc. Si los móviles "ven" la red pero no conectan → usar Método C.

#### Método B — Linux Mint (Cinnamon) Connection Sharing

1. `Network Connections` → `+` → `Wi-Fi`
2. Mode: `Hotspot` · SSID: `MD-KARAOKE` · Security: `WPA2 Personal` · Password: `karaoke2026`
3. Pestaña `IPv4 Settings` → Method: `Shared to other computers` (esto activa NAT + DHCP)
4. Guardar y activar la conexión

#### Método C — nmcli (más fiable, recomendado)

```bash
nmcli device wifi hotspot \
  ifname wlp3s0 \
  ssid "MD-KARAOKE" \
  password "karaoke2026"
```

Sustituye `wlp3s0` por tu `WLAN_DEV`. NetworkManager:
- Crea una conexión `Hotspot` en banda 2.4GHz
- Activa NAT (iptables/nftables) entre la WiFi AP y la ruta de Internet (USB tethering)
- Levanta un DHCP server en la WiFi (subred típica `10.42.0.0/24`, gateway `10.42.0.1`)

Para personalizar la subred (no recomendado salvo conflicto):

```bash
nmcli connection modify Hotspot ipv4.addresses 192.168.42.1/24 ipv4.method shared
nmcli connection up Hotspot
```

#### Método D — hostapd manual (no necesario hoy)

Más control (canal, banda 5GHz, MAC filtering), pero más superficie de error. Stack: `hostapd` + `dnsmasq` (DHCP/DNS) + `iptables` (NAT manual).
**No lo documentamos en detalle aquí** — si necesitas este nivel, mejor en otra sesión.

### Paso 3 — Validación tras setup

```bash
# 3.1 — La interfaz WiFi tiene IP de la subred AP
ip addr show wlp3s0
# Esperado: inet 10.42.0.1/24 (default nmcli) o 192.168.42.1/24 (si la cambiaste)

# 3.2 — La conexión Hotspot está activa
nmcli connection show --active
# Debe aparecer "Hotspot" con DEVICE = wlp3s0

# 3.3 — DHCP corriendo (NM usa dnsmasq embebido)
ss -lup | grep ":67 "   # Debe escuchar en wlp3s0:67

# 3.4 — Forwarding habilitado (NM lo hace solo, pero comprobar)
sysctl net.ipv4.ip_forward   # Debe imprimir net.ipv4.ip_forward = 1

# 3.5 — La IP LAN del AP es la que sirve los puertos del stack
AP_IP=$(ip -4 addr show wlp3s0 | grep -oE 'inet [0-9.]+' | awk '{print $2}')
echo "Mic URL para cantantes: http://$AP_IP:8080/player/mic.html"
```

Desde un móvil:

1. Ajustes WiFi → debe aparecer **MD-KARAOKE**
2. Conectar con password `karaoke2026`
3. Tras conectar, el móvil obtiene IP `10.42.0.X` (DHCP)
4. Abrir browser → `http://10.42.0.1:8080/player/mic.html` (o la IP que viste en paso 3.5)

### Paso 4 — CRÍTICO: HTTPS y `getUserMedia` en móvil

> **Esto es lo que rompe el mic en WiFi AP.** Léelo entero antes del evento.

Los navegadores móviles modernos (Chrome Android, Safari iOS) bloquean `navigator.mediaDevices.getUserMedia()` en **HTTP plano** salvo que el origen sea `localhost` o `127.0.0.1`. La WiFi AP sirve `http://10.42.0.1:8080` → **NO es un origen seguro** → el botón "PULSA PARA CANTAR" lanzará un error de permiso.

Tres soluciones, de mejor a peor:

#### A) Tailscale Funnel (RECOMENDADA si está disponible)

Cada cantante usa **su propio tethering** o WiFi (no la AP del Lenovo) y entra por la URL pública:

```
https://zen-admin-oclaw.tailc9332.ts.net/karaoke
```

- ✅ HTTPS real → `getUserMedia` funciona en todos los móviles
- ✅ No depende de que el cantante esté en la misma WiFi
- ✅ Cero configuración en el móvil
- ⚠️ Requiere que cada cantante tenga datos móviles propios
- ⚠️ Latencia ligeramente mayor (~50-150 ms extra vs LAN)

**Si esta opción es viable → no montes la WiFi AP del Lenovo en absoluto.** Es más simple y robusto.

#### B) ZenSit Root CA + Caddy local con TLS

Instalas el CA root de ZenSit en cada móvil cantante y sirves el player con TLS desde el propio Lenovo:

```bash
# En el Lenovo: Caddy escucha 443 con cert firmado por ZenSit CA
# /etc/caddy/Caddyfile:
mic.karaoke.zensitpro.pro {
  tls /path/to/zensit-cert.pem /path/to/zensit-key.pem
  handle /player/* { reverse_proxy localhost:8080 }
  handle /mic-ws*  { reverse_proxy localhost:8081 }
}
```

En cada móvil: instalar `zensit-root-ca.crt` en Ajustes → Seguridad → Instalar certificado.

- ✅ HTTPS local → `getUserMedia` funciona
- ✅ Bajo latencia (LAN)
- ❌ Instalar CA en N móviles es fricción alta para evento
- ❌ Necesitas resolver `mic.karaoke.zensitpro.pro` localmente (dnsmasq en el AP)

Solo viable si: pocos cantantes (≤5) o si los móviles ya tienen el CA instalado.

#### C) Aceptar que el mic NO funciona vía WiFi AP

Los cantantes usan la WiFi AP solo para ver **monitor de canciones / cola / pedir canción** (todo HTTP funciona, solo `getUserMedia` falla). Para el mic en vivo, usan Tailscale Funnel desde sus datos móviles.

Modelo híbrido razonable: la WiFi AP gestiona "ver y pedir", Funnel gestiona "cantar".

### Sin soporte AP de la tarjeta (`iw list` no muestra `* AP`)

Opciones:

1. **Adaptador USB WiFi externo** que sí soporte AP (revisa específicamente: Alfa AWUS036ACH, TP-Link Archer T2U Plus, RT5370-based). Conectar al Lenovo y crear el AP en ese device.
2. **No montar AP**. Cada cantante tetherea a Diego o usa Tailscale Funnel desde sus datos.

### Apagar el AP al final

```bash
nmcli connection down Hotspot
# Opcional: eliminar la conexión guardada
nmcli connection delete Hotspot
```

Si arrancaste por GNOME Settings: mismo menú → `Turn Off Hotspot`.

---

## 7. Abrir el player en el equipo de display (TV/proyector)

### 7.1 Si el Lenovo ES el equipo conectado al proyector (HDMI directo)

```bash
# Chrome/Chromium fullscreen apuntando al player local
chromium --kiosk --autoplay-policy=no-user-gesture-required \
  "http://localhost:8080/player/"
```

O sin --kiosk:

```bash
chromium "http://localhost:8080/player/"
# Después: tecla F para fullscreen del player (gestionado por la app)
```

### 7.2 Si el equipo de display es OTRO (Mecool Android TV, otra laptop, etc.)

En el equipo de display, abrir browser → `http://<IP-del-Lenovo>:8080/player/`

### 7.3 Atajos de teclado en el player

| Tecla | Acción |
|---|---|
| `Espacio` | Play / Pausa |
| `←` / `→` | Anterior / Siguiente |
| `↑` | Atrasar letra 0.5s (letra aparece más tarde) |
| `↓` | Adelantar letra 0.5s |
| `F` | Fullscreen toggle |
| `D` | Debug overlay (semáforo de sync) |

---

## 8. Distribuir el mic a los cantantes

URL del mic: `http://<LAN_IP>:8080/player/mic.html`

QR pre-generado: `player/mic-qr.png` (apunta a la URL LAN — regenerar si la IP cambia con `scripts/make-mic-qr.sh` si existe; si no, hacer QR online con esa URL).

El cantante:
1. Conecta a la **misma WiFi** del evento
2. Escanea el QR o pone la URL
3. Pone su nombre → "Conectar"
4. Permitir micrófono del browser
5. Habla — su voz se mezcla con la música por TV

---

## 9. Panel admin (móvil/tablet del operador)

URL: `http://<LAN_IP>:8082/`

Login: `diego` / `D13g0$$2026`

Funciones operativas durante el evento:
- Ver cantantes conectados, mute selectivo
- Control remoto del player (play/pausa/next/prev/seek)
- Descargar nueva canción YouTube on-the-fly (yt-dlp → karaoke → LRC search → rebuild)
- Editar LRC en línea si una letra está mal sincronizada

---

## 10. Plan B (si el stack falla en pleno evento)

> El plan B precargado en USB (MP3 + LRC en mismo basename) reproducible con VLC nativo. Ver `docs/plan-b-vlc-android.md`.

Para regenerar el USB plan B:

```bash
bash scripts/prep-usb.sh /media/<USB-mount>
```

---

## 11. Diagnóstico durante el evento

### El player no carga la canción

```bash
# ¿npx serve sigue vivo?
lsof -i :8080
# ¿playlist.json válido?
python3 -c "import json; print(len(json.load(open('player/playlist.json'))))"
# ¿El MP3 existe?
ls -la songs-karaoke/ | head -5
```

### No suena nada por TV

```bash
pactl get-default-sink   # Debe NO ser karaoke_mic
pactl set-sink-volume @DEFAULT_SINK@ 80%   # Sube volumen
paplay /usr/share/sounds/freedesktop/stereo/bell.oga   # Test
```

### El mic del móvil no transmite

```bash
# ¿mic-server vivo?
lsof -i :8081
# ¿WebSocket acepta?
python3 - << EOF
import asyncio, websockets
async def t():
    async with websockets.connect("ws://localhost:8081/?name=diag") as ws:
        await ws.send(b"\x00"*64)
        print("ok")
asyncio.run(t())
EOF
# ¿pacat se está ejecutando? (un proceso por cada cliente activo)
pgrep -af pacat
```

### El admin no responde

```bash
lsof -i :8082
tail -30 admin/logs/admin-server.log
# Reinicio limpio:
pkill -f "admin/server.py"; nohup python3 admin/server.py > admin/logs/admin-server.log 2>&1 &
```

---

## 12. Apagar todo limpiamente al final

En el terminal donde corre `start-event.sh`: `Ctrl+C`.

Si se quedó algún proceso suelto:

```bash
pkill -f "admin/server.py"
pkill -f "mic-server.py"
pkill -f "npx serve"
pkill -f "node.*serve"
# Liberar el sink virtual (opcional)
pactl unload-module module-loopback
pactl unload-module module-null-sink
```
