Compare commits

...

17 Commits

Author SHA1 Message Date
deivid b4cc8770a7 reproduce ganador en dispositivo seleccionado si lo permite
- play_winner acepta device_id del formulario y verifica que el
  dispositivo exista y no sea restringido antes de reproducir
- corrige construcción del URI según spotify_type del ganador
  (antes siempre usaba spotify:playlist: independiente del tipo)
- errores se muestran en la página admin en lugar de HTTPException cruda
- template sincroniza device_id activo al campo oculto del formulario
- dispositivos restringidos se marcan en el selector del admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 19:29:29 -04:00
deivid 15e7324144 Actualizar docker-compose.yml 2026-04-24 18:58:31 -04:00
David Inostroza 152a974533 apunta env_file a ruta absoluta del archivo de configuración
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:51:43 -04:00
David Inostroza 22ee2b58ad mueve selector de dispositivo al panel de admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:51:41 -04:00
David Inostroza d88547e310 reinicia votos al activar ventana de votación y al reproducir ganador
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:51:38 -04:00
deivid ca021cc3f7 agrega toggle de modo claro/oscuro con persistencia en localStorage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:20:08 -04:00
deivid 36a40938c7 corrige zona horaria en votación: TZ=America/Santiago en contenedor
El contenedor corría en UTC causando que la ventana horaria no
coincidiera con la hora local. Se agrega TZ en docker-compose y se
muestra la hora actual del servidor en el panel de votación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:16:39 -04:00
deivid 2abe7b47fd corrige nombre de playlists curadas: elimina fields= en llamada a API
El parámetro fields="name,description,images" causaba error en playlists
curadas de Spotify (ej. "This Is ..."), haciendo que el nombre cayera
al fallback con el spotify_id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:06:03 -04:00
deivid d47f6f4a52 soporta URLs de Spotify con prefijo de idioma (intl-es, intl-pt, etc.)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:55:45 -04:00
deivid 01f04c44d9 persiste datos en ./data/ montado como volumen del host
Reemplaza los bind mounts individuales de cantina.db y .spotify_cache
por un único directorio ./data/ montado en /app/data. El entrypoint
crea el directorio y los archivos vacíos si no existen, evitando que
Docker los cree como directorios al hacer un despliegue limpio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:54:38 -04:00
deivid a0da1bf420 agrega enlace a configuración de votación en panel admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:47:12 -04:00
deivid 809f35fc78 rediseña panel de playlists: layout vertical en lista
Cambia la grilla de cajas a una lista vertical donde cada playlist
ocupa una fila con imagen/emoji a la izquierda y nombre + descripción
a la derecha. En modo votación, la barra y conteo quedan en el bloque
de info y el botón de voto se desplaza al extremo derecho.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 17:43:16 -04:00
deivid 0c2b20011b conecta el contenedor a la red externa containers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:16:45 -04:00
deivid 7651d64b5e elimina HTTPS: servidor corre en HTTP puro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:12:16 -04:00
deivid c6d66e66c6 agrega botón reproducir en panel de playlists admin
Cada fila del mantenedor ahora tiene un botón "▶ Reproducir" que fuerza
la reproducción inmediata en Spotify, sin necesidad de ir al reproductor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:59:23 -04:00
deivid 808ddd889d agrega botón Admin en navbar con detección de sesión
- Nuevo endpoint GET /admin/status devuelve si el usuario está logueado como admin
- Navbar muestra botón "Admin" que lleva a /admin/login si no hay sesión,
  o "⚙ Admin" → /admin/playlists si ya está autenticado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:59:16 -04:00
deivid 94cda7293f mejora vista de votación: modal, alineación y auto-reproducción del ganador
- Tarjetas de votación ahora abren el modal de canciones al hacer clic en la imagen/nombre
- Botón "Votar" siempre alineado al fondo independiente del alto de cada tarjeta
- Muestra descripción de la playlist en modo votación
- Emoji de playlist escala proporcionalmente usando container queries (55cqi)
- Al cerrar la votación, reproduce automáticamente la playlist con más votos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:59:06 -04:00
15 changed files with 357 additions and 181 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"Bash(python3 -m venv .venv)",
"Bash(.venv/bin/pip install *)",
"Bash(.venv/bin/python *)",
"Bash(git -C /home/deivid/spotify-cantina status)"
"Bash(git -C /home/deivid/spotify-cantina status)",
"Bash(git *)"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
.env
.spotify_cache
cantina.db
data/
__pycache__/
*.pyc
.venv/
-3
View File
@@ -2,9 +2,6 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+1 -1
View File
@@ -8,7 +8,7 @@ class Settings(BaseSettings):
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin123"
SECRET_KEY: str = "cambia-esta-clave-secreta"
DATABASE_URL: str = "sqlite:///./cantina.db"
DATABASE_URL: str = "sqlite:///./data/cantina.db"
SPOTIFY_SCOPES: str = (
"user-read-playback-state "
+77 -10
View File
@@ -20,7 +20,7 @@ templates = Jinja2Templates(directory="app/templates")
_VALID_TYPES = {"playlist", "album", "artist", "track"}
_URI_RE = re.compile(r"spotify:(playlist|album|artist|track):([A-Za-z0-9]+)")
_URL_RE = re.compile(r"open\.spotify\.com/(playlist|album|artist|track)/([A-Za-z0-9]+)")
_URL_RE = re.compile(r"open\.spotify\.com/(?:[a-z-]+/)?(playlist|album|artist|track)/([A-Za-z0-9]+)")
_BARE_ID_RE = re.compile(r"^[A-Za-z0-9]{22}$")
_TYPE_LABELS = {
@@ -45,9 +45,9 @@ def _extract_spotify_item(value: str) -> tuple[str, str] | None:
def _fetch_metadata(sp, spotify_id: str, spotify_type: str) -> dict:
if spotify_type == "playlist":
data = sp.playlist(spotify_id, fields="name,description,images")
data = sp.playlist(spotify_id)
return {
"name": data["name"],
"name": data.get("name") or spotify_id,
"description": data.get("description") or "",
"image_url": data["images"][0]["url"] if data.get("images") else "",
}
@@ -84,6 +84,11 @@ def _require_admin(request: Request):
# ── Login ──────────────────────────────────────────────────────────────────────
@router.get("/status")
def admin_status(request: Request):
return {"logged_in": bool(request.session.get("admin_logged_in"))}
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("admin/login.html", {"request": request, "error": None})
@@ -239,6 +244,7 @@ def voting_admin(request: Request, db: Session = Depends(get_db)):
"config": config,
"results": results,
"total_votes": total_votes,
"server_time": datetime.now().strftime("%H:%M:%S"),
"error": None,
"success": None,
},
@@ -256,6 +262,8 @@ def update_voting_config(
_require_admin(request)
config = _get_or_create_config(db)
now_str = datetime.now().strftime("%H:%M:%S")
try:
datetime.strptime(start_time, "%H:%M")
datetime.strptime(end_time, "%H:%M")
@@ -268,6 +276,7 @@ def update_voting_config(
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"server_time": now_str,
"error": "Formato de hora inválido. Use HH:MM",
"success": None,
},
@@ -283,18 +292,31 @@ def update_voting_config(
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"server_time": now_str,
"error": "La hora de inicio debe ser anterior a la hora de fin",
"success": None,
},
status_code=400,
)
was_active = config.is_active
new_active = is_active == "on"
config.start_time = start_time
config.end_time = end_time
config.is_active = is_active == "on"
config.is_active = new_active
db.commit()
votes_reset = False
if new_active and not was_active:
db.query(Vote).delete()
db.commit()
votes_reset = True
results = _vote_results(db)
success_msg = "Configuración guardada"
if votes_reset:
success_msg = "Configuración guardada · Votos reiniciados para la nueva ventana"
return templates.TemplateResponse(
"admin/voting.html",
{
@@ -302,8 +324,9 @@ def update_voting_config(
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"server_time": now_str,
"error": None,
"success": "Configuración guardada",
"success": success_msg,
},
)
@@ -317,19 +340,63 @@ def reset_votes(request: Request, db: Session = Depends(get_db)):
@router.post("/voting/play-winner")
def play_winner(request: Request, db: Session = Depends(get_db)):
def play_winner(
request: Request,
device_id: str = Form(default=""),
db: Session = Depends(get_db),
):
_require_admin(request)
results = _vote_results(db)
config = _get_or_create_config(db)
now_str = datetime.now().strftime("%H:%M:%S")
def _error(msg: str):
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"server_time": now_str,
"error": msg,
"success": None,
},
status_code=400,
)
if not results or results[0]["votes"] == 0:
raise HTTPException(status_code=400, detail="No hay votos registrados")
return _error("No hay votos registrados")
winner = results[0]["playlist"]
try:
sp = spotify.get_client()
sp.start_playback(context_uri=f"spotify:playlist:{winner.spotify_id}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if device_id:
devices = sp.devices().get("devices", [])
device = next((d for d in devices if d["id"] == device_id), None)
if not device:
return _error("El dispositivo seleccionado ya no está disponible")
if device.get("is_restricted"):
return _error(
f"El dispositivo «{device['name']}» no permite reproducción remota"
)
kwargs: dict = {}
if device_id:
kwargs["device_id"] = device_id
if winner.spotify_type == "track":
kwargs["uris"] = [f"spotify:track:{winner.spotify_id}"]
else:
kwargs["context_uri"] = f"spotify:{winner.spotify_type}:{winner.spotify_id}"
sp.start_playback(**kwargs)
except Exception as e:
return _error(f"Error al reproducir: {e}")
db.query(Vote).delete()
db.commit()
return RedirectResponse(url="/admin/voting", status_code=303)
+1 -1
View File
@@ -21,7 +21,7 @@ def spotify_callback(code: str):
@router.get("/logout")
def spotify_logout():
try:
os.remove(".spotify_cache")
os.remove("data/.spotify_cache")
except FileNotFoundError:
pass
return RedirectResponse(url="/auth/login")
+1 -1
View File
@@ -8,7 +8,7 @@ _oauth = SpotifyOAuth(
client_secret=settings.SPOTIFY_CLIENT_SECRET,
redirect_uri=settings.SPOTIFY_REDIRECT_URI,
scope=settings.SPOTIFY_SCOPES,
cache_path=".spotify_cache",
cache_path="data/.spotify_cache",
open_browser=False,
)
+35 -5
View File
@@ -5,9 +5,12 @@
<div class="admin-container">
<div class="admin-header">
<h1>Mantenedor</h1>
<a href="/auth/logout" class="btn-sm btn-danger"
onclick="return confirm('¿Desconectar la cuenta de Spotify?')">⏏ Cuenta Spotify</a>
<a href="/admin/logout" class="btn-sm btn-danger">Cerrar sesión</a>
<div style="display:flex;gap:.5rem">
<a href="/admin/voting" class="btn-sm">🗳 Votación</a>
<a href="/auth/logout" class="btn-sm btn-danger"
onclick="return confirm('¿Desconectar la cuenta de Spotify?')">⏏ Cuenta Spotify</a>
<a href="/admin/logout" class="btn-sm btn-danger">Cerrar sesión</a>
</div>
</div>
{% if error %}
@@ -74,6 +77,8 @@
</td>
<td class="td-desc">{{ pl.description[:80] }}{% if pl.description | length > 80 %}…{% endif %}</td>
<td class="td-actions">
<button class="btn-sm btn-play-now" id="play-btn-{{ pl.id }}"
onclick="playNow('{{ pl.spotify_id }}', '{{ pl.spotify_type }}', {{ pl.id }})">▶ Reproducir</button>
<button class="btn-sm" onclick="toggleEdit({{ pl.id }})">✏️ Editar</button>
<form method="post" action="/admin/playlists/{{ pl.id }}/delete"
onsubmit="return confirm('¿Eliminar {{ pl.name }}?')">
@@ -133,6 +138,29 @@
</div>
<script>
async function playNow(spotifyId, spotifyType, plId) {
const btn = document.getElementById('play-btn-' + plId);
btn.disabled = true;
btn.textContent = '⏳';
const body = spotifyType === 'track'
? { uris: [`spotify:track:${spotifyId}`] }
: { context_uri: `spotify:${spotifyType}:${spotifyId}` };
try {
const res = await fetch('/player/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
btn.textContent = res.ok ? '✓ Reproduciendo' : '✗ Error';
} catch (_) {
btn.textContent = '✗ Error';
}
setTimeout(() => { btn.disabled = false; btn.textContent = '▶ Reproducir'; }, 2500);
}
function toggleEdit(id) {
const row = document.getElementById('edit-row-' + id);
const visible = row.style.display !== 'none';
@@ -149,6 +177,8 @@ function clearEmoji(id) {
<style>
.td-actions { display: flex; gap: .4rem; align-items: center; }
.btn-play-now { color: var(--green); border-color: rgba(29,185,84,.4); }
.btn-play-now:hover { background: rgba(29,185,84,.12); }
.edit-row td { background: var(--surface2); padding: .75rem 1rem; }
.edit-form { display: flex; flex-direction: column; gap: .75rem; }
@@ -167,7 +197,7 @@ function clearEmoji(id) {
.emoji-picker { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .35rem; }
.emoji-opt {
background: var(--surface);
border: 1px solid #333;
border: 1px solid var(--border2);
border-radius: 6px;
font-size: 1.15rem;
width: 34px; height: 34px;
@@ -175,7 +205,7 @@ function clearEmoji(id) {
display: flex; align-items: center; justify-content: center;
transition: background .12s, transform .1s;
}
.emoji-opt:hover { background: #333; transform: scale(1.15); }
.emoji-opt:hover { background: var(--surface3); transform: scale(1.15); }
.emoji-thumb { font-size: 1.6rem; }
+76 -7
View File
@@ -54,15 +54,30 @@
<button type="submit" class="btn-primary">Guardar configuración</button>
</form>
<div class="status-badge {% if config.is_active %}status-active{% else %}status-inactive{% endif %}">
{% if config.is_active %}
🟢 Activa · Ventana: {{ config.start_time }} {{ config.end_time }}
{% else %}
🔴 Inactiva
{% endif %}
<div class="status-row">
<div class="status-badge {% if config.is_active %}status-active{% else %}status-inactive{% endif %}">
{% if config.is_active %}
🟢 Activa · Ventana: {{ config.start_time }} {{ config.end_time }}
{% else %}
🔴 Inactiva
{% endif %}
</div>
<div class="server-time">🕐 Hora del servidor: <strong>{{ server_time }}</strong></div>
</div>
</div>
<!-- Selector de dispositivo -->
<div class="card">
<h2>Dispositivo de reproducción</h2>
<div class="device-row">
<select id="device-select" class="input" onchange="setDevice(this.value)" style="flex:1">
<option value="">Cargando dispositivos...</option>
</select>
<button class="btn-sm" onclick="loadDevices()">↻ Actualizar</button>
</div>
<div id="device-msg" style="font-size:.8rem;color:var(--text-muted);margin-top:.4rem"></div>
</div>
<!-- Resultados -->
<div class="card">
<div class="results-header">
@@ -70,6 +85,7 @@
<div style="display:flex;gap:.5rem;align-items:center">
{% if results and results[0].votes > 0 %}
<form method="post" action="/admin/voting/play-winner">
<input type="hidden" id="play-winner-device-id" name="device_id" value="">
<button type="submit" class="btn-primary btn-sm">
▶ Reproducir ganador
</button>
@@ -118,7 +134,58 @@
</div>
<script>
function _syncWinnerDevice(deviceId) {
const input = document.getElementById('play-winner-device-id');
if (input) input.value = deviceId || '';
}
async function loadDevices() {
const sel = document.getElementById('device-select');
const msg = document.getElementById('device-msg');
sel.innerHTML = '<option value="">Cargando...</option>';
msg.textContent = '';
try {
const res = await fetch('/player/devices');
const devices = await res.json();
if (!devices.length) {
sel.innerHTML = '<option value="">Sin dispositivos activos</option>';
_syncWinnerDevice('');
return;
}
sel.innerHTML = devices.map(d =>
`<option value="${d.id}"${d.is_active ? ' selected' : ''}>${d.name} (${d.type})${d.is_restricted ? ' — sin control remoto' : ''}</option>`
).join('');
const active = devices.find(d => d.is_active);
_syncWinnerDevice(active ? active.id : devices[0].id);
} catch (_) {
sel.innerHTML = '<option value="">Error al cargar dispositivos</option>';
_syncWinnerDevice('');
}
}
async function setDevice(deviceId) {
if (!deviceId) return;
const msg = document.getElementById('device-msg');
_syncWinnerDevice(deviceId);
try {
await fetch('/player/device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId }),
});
msg.textContent = 'Dispositivo actualizado.';
setTimeout(() => msg.textContent = '', 2500);
} catch (_) {
msg.textContent = 'Error al cambiar dispositivo.';
}
}
loadDevices();
</script>
<style>
.device-row { display: flex; align-items: center; gap: .5rem; }
.voting-form { display: flex; flex-direction: column; gap: 1rem; }
.time-row { display: flex; align-items: flex-end; gap: 1rem; }
.time-field { display: flex; flex-direction: column; gap: .3rem; flex: 1; }
@@ -140,6 +207,8 @@
}
.status-active { background: rgba(29,185,84,.15); color: var(--green); border: 1px solid rgba(29,185,84,.3); }
.status-inactive { background: rgba(136,136,136,.1); color: var(--text-muted); border: 1px solid #333; }
.status-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.server-time { font-size: .8rem; color: var(--text-muted); }
.results-header { display: flex; justify-content: space-between; align-items: center; }
.results-list { display: flex; flex-direction: column; gap: .5rem; margin-top: .25rem; }
@@ -157,7 +226,7 @@
.result-info { flex: 1; display: flex; flex-direction: column; gap: .3rem; }
.result-name { font-size: .9rem; font-weight: 500; display: flex; align-items: center; gap: .5rem; }
.winner-badge { background: rgba(255,215,0,.15); color: gold; font-size: .75rem; padding: 1px 6px; border-radius: 10px; border: 1px solid rgba(255,215,0,.3); }
.result-bar-wrap { height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.result-bar-wrap { height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; }
.result-bar { height: 100%; background: var(--green); border-radius: 3px; transition: width .4s; }
.result-count { display: flex; flex-direction: column; align-items: flex-end; min-width: 50px; }
.result-count strong { font-size: 1.1rem; }
+27 -3
View File
@@ -1,9 +1,10 @@
<!DOCTYPE html>
<html lang="es">
<html lang="es" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Spotify Cantina{% endblock %}</title>
<script>(function(){var t=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-theme',t);})()</script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
@@ -11,11 +12,34 @@
<div class="brand">🎵 Cantina</div>
<div class="nav-links">
<a href="/">Reproductor</a>
<a href="/admin/playlists">Playlists</a>
<a href="/admin/voting">Votación</a>
<a href="/stats/">Estadísticas</a>
<a id="admin-nav-btn" href="/admin/login" class="btn-sm">Admin</a>
<button id="theme-toggle" class="theme-toggle" onclick="toggleTheme()" title="Cambiar tema">☀️</button>
</div>
</nav>
<script>
fetch('/admin/status').then(r => r.json()).then(data => {
const btn = document.getElementById('admin-nav-btn');
if (data.logged_in) {
btn.textContent = '⚙ Admin';
btn.href = '/admin/playlists';
}
}).catch(() => {});
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
document.getElementById('theme-toggle').textContent = next === 'dark' ? '☀️' : '🌙';
}
(function syncToggleIcon() {
const t = document.documentElement.getAttribute('data-theme') || 'dark';
const btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = t === 'dark' ? '☀️' : '🌙';
})();
</script>
<main>
{% block content %}{% endblock %}
</main>
+55 -60
View File
@@ -44,17 +44,6 @@
<span class="volume-icon">🔊</span>
</div>
<!-- Selector de dispositivo -->
<div class="device-section">
<label class="section-label">Dispositivo</label>
<div class="device-row">
<select id="device-select" class="select" onchange="setDevice(this.value)">
<option value="">Cargando dispositivos...</option>
</select>
<button class="btn-sm" onclick="loadDevices()"></button>
</div>
</div>
<!-- Playlists / Votación -->
<div class="playlist-section">
<div class="playlist-section-header">
@@ -88,7 +77,6 @@
<script>
let isPlaying = false;
let currentDeviceId = null;
let votingOpen = false;
let myVotedPlaylistId = null;
let cooldownRemaining = 0;
@@ -128,8 +116,7 @@ async function fetchCurrent() {
async function togglePlay() {
const endpoint = isPlaying ? '/player/pause' : '/player/play';
const body = (!isPlaying && currentDeviceId) ? JSON.stringify({ device_id: currentDeviceId }) : '{}';
await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
setTimeout(fetchCurrent, 300);
}
@@ -155,29 +142,6 @@ function setVolume(val) {
}, 200);
}
async function loadDevices() {
const res = await fetch('/player/devices');
const devices = await res.json();
const sel = document.getElementById('device-select');
sel.innerHTML = devices.length === 0
? '<option value="">Sin dispositivos activos</option>'
: devices.map(d => `<option value="${d.id}">${d.name} (${d.type})</option>`).join('');
if (devices.length > 0) {
currentDeviceId = devices[0].id;
sel.value = currentDeviceId;
}
}
function setDevice(deviceId) {
if (!deviceId) return;
currentDeviceId = deviceId;
fetch('/player/device', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId }),
});
}
async function playItem(spotifyId, spotifyType) {
const uri = `spotify:${spotifyType}:${spotifyId}`;
const body = {};
@@ -186,7 +150,6 @@ async function playItem(spotifyId, spotifyType) {
} else {
body.context_uri = uri;
}
if (currentDeviceId) body.device_id = currentDeviceId;
await fetch('/player/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -243,9 +206,18 @@ async function fetchVotingStatus() {
const res = await fetch('/voting/status');
const data = await res.json();
const wasOpen = votingOpen;
votingOpen = data.is_open;
myVotedPlaylistId = data.my_last_vote_playlist_id;
if (wasOpen && !votingOpen && data.playlists?.length) {
const totalVotes = data.playlists.reduce((s, p) => s + p.votes, 0);
if (totalVotes > 0) {
const winner = data.playlists.reduce((best, p) => p.votes > best.votes ? p : best);
playItem(winner.spotify_id, winner.spotify_type);
}
}
// Solo sincronizar cooldown desde servidor si no hay timer local activo
if (cooldownRemaining <= 0 && data.cooldown_remaining > 0) {
startCooldownTick(data.cooldown_remaining);
@@ -291,13 +263,18 @@ function renderPlaylists(playlists, isVoting, myVote) {
const btnLabel = cooldownRemaining > 0 ? `${cooldownRemaining}s` : '👍 Votar';
const btnDisabled = cooldownRemaining > 0 ? 'disabled' : '';
return `
<div class="playlist-card clickable voting-card ${isVoted ? 'voted' : ''}">
${img}
<div class="playlist-card-name">${pl.name}</div>
<div class="vote-bar-wrap"><div class="vote-bar" style="width:${pct}%"></div></div>
<div class="vote-info">
<span class="vote-count">${pl.votes} votos</span>
${isVoted ? '<span class="voted-badge">✓ último voto</span>' : ''}
<div class="playlist-card voting-card ${isVoted ? 'voted' : ''}">
<div class="playlist-card-clickable clickable" onclick='openTracksModal(${JSON.stringify(pl)})'>
${img}
<div class="playlist-card-info">
<div class="playlist-card-name">${pl.name}</div>
${pl.description ? `<div class="playlist-card-desc">${pl.description}</div>` : ''}
<div class="vote-bar-wrap"><div class="vote-bar" style="width:${pct}%"></div></div>
<div class="vote-info">
<span class="vote-count">${pl.votes} votos</span>
${isVoted ? '<span class="voted-badge">✓ último voto</span>' : ''}
</div>
</div>
</div>
<button class="vote-btn" ${btnDisabled} onclick="castVote(${pl.id})">${btnLabel}</button>
</div>`;
@@ -305,7 +282,10 @@ function renderPlaylists(playlists, isVoting, myVote) {
return `
<div class="playlist-card clickable" onclick='openTracksModal(${JSON.stringify(pl)})'>
${img}
<div class="playlist-card-name">${pl.name}</div>
<div class="playlist-card-info">
<div class="playlist-card-name">${pl.name}</div>
${pl.description ? `<div class="playlist-card-desc">${pl.description}</div>` : ''}
</div>
</div>`;
}
}).join('');
@@ -378,7 +358,6 @@ function closeTracksModal(event) {
async function playTrackFromModal(trackId) {
const body = { uris: [`spotify:track:${trackId}`] };
if (currentDeviceId) body.device_id = currentDeviceId;
await fetch('/player/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -393,7 +372,6 @@ document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeTracksModal();
});
loadDevices();
fetchCurrent();
fetchVotingStatus();
@@ -416,16 +394,33 @@ setInterval(fetchVotingStatus, 5000);
gap: .5rem;
}
.voting-card { cursor: pointer; position: relative; }
.voting-card { position: relative; }
.playlist-card-clickable {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
cursor: pointer;
}
.playlist-card-clickable:hover .playlist-card-name { color: var(--green); }
.playlist-card-desc {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.voting-card.voted { border: 2px solid var(--green); }
.vote-bar-wrap {
width: 100%;
height: 4px;
background: #333;
background: var(--surface3);
border-radius: 2px;
overflow: hidden;
margin-top: 2px;
margin-top: 4px;
}
.vote-bar { height: 100%; background: var(--green); border-radius: 2px; transition: width .4s; }
@@ -450,9 +445,7 @@ setInterval(fetchVotingStatus, 5000);
}
.vote-btn {
width: 100%;
margin-top: 4px;
padding: .35rem 0;
padding: .4rem .75rem;
background: var(--green);
color: #000;
font-weight: 700;
@@ -461,10 +454,12 @@ setInterval(fetchVotingStatus, 5000);
border-radius: 6px;
cursor: pointer;
transition: background .15s, opacity .15s;
white-space: nowrap;
flex-shrink: 0;
}
.vote-btn:hover:not(:disabled) { background: var(--green-dark); }
.vote-btn:disabled {
background: #333;
background: var(--surface3);
color: var(--text-muted);
cursor: not-allowed;
opacity: .8;
@@ -482,8 +477,8 @@ setInterval(fetchVotingStatus, 5000);
padding: 1rem;
}
.modal-box {
background: #1a1a1a;
border: 1px solid #333;
background: var(--modal-bg);
border: 1px solid var(--border2);
border-radius: 12px;
width: 100%;
max-width: 520px;
@@ -497,7 +492,7 @@ setInterval(fetchVotingStatus, 5000);
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem;
border-bottom: 1px solid #2a2a2a;
border-bottom: 1px solid var(--surface2);
gap: .8rem;
}
.modal-title-wrap {
@@ -540,7 +535,7 @@ setInterval(fetchVotingStatus, 5000);
border-radius: 4px;
flex-shrink: 0;
}
.modal-close:hover { color: #fff; background: #2a2a2a; }
.modal-close:hover { color: var(--text); background: var(--surface2); }
.modal-track-list {
overflow-y: auto;
padding: .4rem 0;
@@ -553,7 +548,7 @@ setInterval(fetchVotingStatus, 5000);
cursor: pointer;
transition: background .12s;
}
.modal-track-row:hover { background: #2a2a2a; }
.modal-track-row:hover { background: var(--surface2); }
.modal-track-num {
font-size: .8rem;
color: var(--text-muted);
+9 -2
View File
@@ -4,7 +4,14 @@ services:
ports:
- "8000:8000"
env_file: .env
environment:
TZ: America/Santiago
volumes:
- ./cantina.db:/app/cantina.db
- ./.spotify_cache:/app/.spotify_cache
- ./data:/app/data
restart: unless-stopped
networks:
- containers
networks:
containers:
external: true
+3 -16
View File
@@ -1,22 +1,9 @@
#!/bin/sh
set -e
# Archivos de datos — crearlos si no existen para que los bind mounts funcionen
touch cantina.db .spotify_cache
# Certificado SSL autofirmado — solo se genera una vez
if [ ! -f cert.pem ] || [ ! -f key.pem ]; then
echo "Generando certificado SSL autofirmado..."
openssl req -x509 -newkey rsa:2048 \
-keyout key.pem -out cert.pem \
-days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
2>/dev/null
fi
mkdir -p data
touch data/cantina.db data/.spotify_cache
exec uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--ssl-keyfile key.pem \
--ssl-certfile cert.pem
--port 8000
-42
View File
@@ -1,42 +0,0 @@
"""Genera un certificado SSL autofirmado para localhost."""
import datetime
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
])
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365))
.add_extension(
x509.SubjectAlternativeName([
x509.DNSName("localhost"),
x509.IPAddress(__import__("ipaddress").IPv4Address("127.0.0.1")),
]),
critical=False,
)
.sign(key, hashes.SHA256())
)
with open("cert.pem", "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
with open("key.pem", "wb") as f:
f.write(key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
))
print("Certificados generados: cert.pem y key.pem")
+69 -29
View File
@@ -5,12 +5,30 @@
--bg: #121212;
--surface: #1e1e1e;
--surface2: #2a2a2a;
--surface3: #333333;
--green: #1db954;
--green-dark: #17a349;
--text: #e0e0e0;
--text-muted: #888;
--red: #e74c3c;
--radius: 10px;
--nav-bg: #000000;
--border: #444444;
--border2: #333333;
--modal-bg: #1a1a1a;
}
[data-theme="light"] {
--bg: #f4f4f4;
--surface: #ffffff;
--surface2: #f0f0f0;
--surface3: #e4e4e4;
--text: #111111;
--text-muted: #666666;
--nav-bg: #f8f8f8;
--border: #d8d8d8;
--border2: #cccccc;
--modal-bg: #ffffff;
}
body {
@@ -28,12 +46,12 @@ a:hover { text-decoration: underline; }
display: flex;
align-items: center;
justify-content: space-between;
background: #000;
background: var(--nav-bg);
padding: 0.75rem 1.5rem;
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--border2);
}
.brand { font-size: 1.1rem; font-weight: 700; color: var(--green); letter-spacing: 1px; }
.nav-links { display: flex; gap: 1.5rem; }
@@ -154,59 +172,67 @@ a:hover { text-decoration: underline; }
flex: 1;
background: var(--surface2);
color: var(--text);
border: 1px solid #444;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.875rem;
}
/* Playlists grid */
/* Playlists list */
.playlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.playlist-card {
background: var(--surface);
border-radius: var(--radius);
padding: 0.6rem;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
padding: 0.75rem;
transition: background 0.15s;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
gap: 0.4rem;
text-align: center;
gap: 0.75rem;
}
.playlist-card.clickable:hover { background: var(--surface2); transform: scale(1.02); cursor: pointer; }
.playlist-card.clickable:active { transform: scale(0.98); }
.playlist-card.clickable { cursor: pointer; }
.playlist-card.clickable:hover { background: var(--surface2); }
.playlist-card.clickable:active { background: #333; }
.playlist-thumb {
width: 100%;
aspect-ratio: 1;
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.playlist-thumb-placeholder {
width: 100%;
aspect-ratio: 1;
width: 52px;
height: 52px;
background: var(--surface2);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-size: 1.8rem;
flex-shrink: 0;
}
.playlist-thumb-emoji { font-size: 1.8rem; }
.playlist-card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.playlist-thumb-emoji { font-size: 2.4rem; }
.playlist-card-name {
font-size: 0.78rem;
font-weight: 500;
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
/* ── Admin ── */
@@ -238,7 +264,7 @@ a:hover { text-decoration: underline; }
.input {
background: var(--surface2);
color: var(--text);
border: 1px solid #444;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
@@ -257,9 +283,9 @@ a:hover { text-decoration: underline; }
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--border2);
}
.table td { padding: 0.6rem; border-bottom: 1px solid #2a2a2a; vertical-align: middle; }
.table td { padding: 0.6rem; border-bottom: 1px solid var(--surface2); vertical-align: middle; }
.table tr:last-child td { border-bottom: none; }
.table-thumb {
@@ -300,14 +326,14 @@ a:hover { text-decoration: underline; }
.btn-sm {
background: var(--surface2);
color: var(--text);
border: 1px solid #444;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 0.7rem;
cursor: pointer;
font-size: 0.82rem;
transition: background 0.15s;
}
.btn-sm:hover { background: #333; }
.btn-sm:hover { background: var(--surface3); }
.btn-danger { background: transparent; color: var(--red); border-color: var(--red); }
.btn-danger:hover { background: var(--red); color: #fff; }
@@ -338,3 +364,17 @@ a:hover { text-decoration: underline; }
/* Misc */
.empty-msg { color: var(--text-muted); font-size: 0.9rem; }
/* ── Theme toggle ── */
.theme-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0.3rem 0.4rem;
border-radius: 6px;
line-height: 1;
transition: background 0.15s;
color: var(--text-muted);
}
.theme-toggle:hover { background: var(--surface2); }