Compare commits
17 Commits
a1b9c0139d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b4cc8770a7 | |||
| 15e7324144 | |||
| 152a974533 | |||
| 22ee2b58ad | |||
| d88547e310 | |||
| ca021cc3f7 | |||
| 36a40938c7 | |||
| 2abe7b47fd | |||
| d47f6f4a52 | |||
| 01f04c44d9 | |||
| a0da1bf420 | |||
| 809f35fc78 | |||
| 0c2b20011b | |||
| 7651d64b5e | |||
| c6d66e66c6 | |||
| 808ddd889d | |||
| 94cda7293f |
@@ -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,6 +1,7 @@
|
||||
.env
|
||||
.spotify_cache
|
||||
cantina.db
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user