commit inicial

This commit is contained in:
2026-04-23 00:39:58 -04:00
commit a1b9c0139d
32 changed files with 2836 additions and 0 deletions
View File
+26
View File
@@ -0,0 +1,26 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SPOTIFY_CLIENT_ID: str
SPOTIFY_CLIENT_SECRET: str
SPOTIFY_REDIRECT_URI: str = "http://127.0.0.1:8000/auth/callback"
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin123"
SECRET_KEY: str = "cambia-esta-clave-secreta"
DATABASE_URL: str = "sqlite:///./cantina.db"
SPOTIFY_SCOPES: str = (
"user-read-playback-state "
"user-modify-playback-state "
"user-read-currently-playing "
"user-read-recently-played "
"playlist-read-private "
"playlist-read-collaborative"
)
class Config:
env_file = ".env"
settings = Settings()
+35
View File
@@ -0,0 +1,35 @@
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def apply_migrations():
"""Aplica migraciones incrementales compatibles con SQLite."""
with engine.connect() as conn:
for sql in [
"ALTER TABLE playlists ADD COLUMN spotify_type VARCHAR NOT NULL DEFAULT 'playlist'",
"ALTER TABLE playlists ADD COLUMN emoji VARCHAR NOT NULL DEFAULT ''",
]:
try:
conn.execute(text(sql))
conn.commit()
except Exception:
pass # columna ya existe
+96
View File
@@ -0,0 +1,96 @@
"""Background task que consulta recently_played cada 30s y persiste el historial."""
import asyncio
import logging
from datetime import datetime, timezone
from app.database import SessionLocal
from app.models import PlayHistory
from app import spotify
logger = logging.getLogger(__name__)
POLL_INTERVAL = 30 # segundos
async def start_poller():
while True:
try:
await asyncio.to_thread(_fetch_and_save)
except Exception as exc:
logger.warning("history_poller error: %s", exc)
await asyncio.sleep(POLL_INTERVAL)
def _fetch_and_save():
if not spotify.is_authenticated():
return
sp = spotify.get_client()
result = sp.current_user_recently_played(limit=50)
items = result.get("items", [])
if not items:
return
# Recolectar IDs de artistas únicos para lookup de géneros en batch
artist_ids: set[str] = set()
for item in items:
for artist in item["track"]["artists"]:
artist_ids.add(artist["id"])
genre_map: dict[str, list[str]] = {}
artist_id_list = list(artist_ids)
for i in range(0, len(artist_id_list), 50):
batch = artist_id_list[i : i + 50]
try:
data = sp.artists(batch)
for a in data.get("artists") or []:
if a:
genre_map[a["id"]] = a.get("genres", [])
except Exception:
pass
db = SessionLocal()
try:
saved = 0
for item in items:
track = item["track"]
played_at = datetime.fromisoformat(
item["played_at"].replace("Z", "+00:00")
)
exists = (
db.query(PlayHistory)
.filter(
PlayHistory.track_id == track["id"],
PlayHistory.played_at == played_at,
)
.first()
)
if exists:
continue
track_artists = track.get("artists", [])
artist_names = [a["name"] for a in track_artists]
genres: set[str] = set()
for a in track_artists:
genres.update(genre_map.get(a["id"], []))
db.add(
PlayHistory(
track_id=track["id"],
track_name=track["name"],
artists=", ".join(artist_names),
genres=", ".join(sorted(genres)),
played_at=played_at,
)
)
saved += 1
if saved:
db.commit()
logger.info("history_poller: guardadas %d reproducciones", saved)
except Exception:
db.rollback()
raise
finally:
db.close()
+45
View File
@@ -0,0 +1,45 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.database import engine, apply_migrations
from app.models import Base
from app.routers import auth, player, admin, voting, stats
from app import spotify
from app.history_poller import start_poller
Base.metadata.create_all(bind=engine)
apply_migrations()
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(start_poller())
yield
task.cancel()
app = FastAPI(title="Spotify Cantina", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="app/templates")
app.include_router(auth.router)
app.include_router(player.router)
app.include_router(admin.router)
app.include_router(voting.router)
app.include_router(stats.router)
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
if not spotify.is_authenticated():
return RedirectResponse(url="/auth/login")
return templates.TemplateResponse("index.html", {"request": request})
+51
View File
@@ -0,0 +1,51 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, UniqueConstraint, Index
from sqlalchemy.sql import func
from app.database import Base
class Playlist(Base):
__tablename__ = "playlists"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
spotify_id = Column(String, nullable=False, unique=True)
spotify_type = Column(String, nullable=False, default="playlist") # playlist | album | artist | track
description = Column(String, default="")
image_url = Column(String, default="")
emoji = Column(String, default="") # si está definido, reemplaza la imagen
created_at = Column(DateTime(timezone=True), server_default=func.now())
class VotingConfig(Base):
"""Single-row table — always query .first()."""
__tablename__ = "voting_config"
id = Column(Integer, primary_key=True)
start_time = Column(String, nullable=False, default="12:00") # "HH:MM"
end_time = Column(String, nullable=False, default="13:00") # "HH:MM"
is_active = Column(Boolean, default=False, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class Vote(Base):
__tablename__ = "votes"
id = Column(Integer, primary_key=True)
playlist_id = Column(Integer, ForeignKey("playlists.id", ondelete="CASCADE"), nullable=False)
voter_token = Column(String, nullable=False)
voted_at = Column(DateTime(timezone=True), nullable=False)
__table_args__ = (Index("ix_vote_voter_token_time", "voter_token", "voted_at"),)
class PlayHistory(Base):
__tablename__ = "play_history"
id = Column(Integer, primary_key=True)
track_id = Column(String, nullable=False)
track_name = Column(String, nullable=False)
artists = Column(String, nullable=False) # "Artist1, Artist2"
genres = Column(String, default="") # "rock, pop"
played_at = Column(DateTime(timezone=True), nullable=False)
__table_args__ = (UniqueConstraint("track_id", "played_at", name="uq_track_played_at"),)
View File
+365
View File
@@ -0,0 +1,365 @@
import os
import re
import signal
import sys
import threading
import time
from datetime import datetime
from fastapi import APIRouter, Request, Depends, Form, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Playlist, Vote, VotingConfig
from app.config import settings
from app import spotify
router = APIRouter(prefix="/admin", tags=["admin"])
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]+)")
_BARE_ID_RE = re.compile(r"^[A-Za-z0-9]{22}$")
_TYPE_LABELS = {
"playlist": ("📋", "Playlist"),
"album": ("💿", "Álbum"),
"artist": ("🎤", "Artista"),
"track": ("🎵", "Canción"),
}
def _extract_spotify_item(value: str) -> tuple[str, str] | None:
"""Retorna (spotify_id, spotify_type) o None si la URL no es válida."""
value = value.strip().split("?")[0].rstrip("/")
if m := _URI_RE.search(value):
return m.group(2), m.group(1)
if m := _URL_RE.search(value):
return m.group(2), m.group(1)
if _BARE_ID_RE.match(value):
return value, "playlist"
return 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")
return {
"name": data["name"],
"description": data.get("description") or "",
"image_url": data["images"][0]["url"] if data.get("images") else "",
}
if spotify_type == "album":
data = sp.album(spotify_id)
artists = ", ".join(a["name"] for a in data.get("artists", []))
return {
"name": f"{data['name']}",
"description": f"{artists} · {str(data.get('release_date', ''))[:4]}",
"image_url": data["images"][0]["url"] if data.get("images") else "",
}
if spotify_type == "artist":
data = sp.artist(spotify_id)
return {
"name": data["name"],
"description": "Artista",
"image_url": data["images"][0]["url"] if data.get("images") else "",
}
if spotify_type == "track":
data = sp.track(spotify_id)
artists = ", ".join(a["name"] for a in data.get("artists", []))
return {
"name": data["name"],
"description": f"{artists} · {data['album']['name']}",
"image_url": data["album"]["images"][0]["url"] if data["album"].get("images") else "",
}
return {"name": spotify_id, "description": "", "image_url": ""}
def _require_admin(request: Request):
if not request.session.get("admin_logged_in"):
raise HTTPException(status_code=302, headers={"Location": "/admin/login"})
# ── Login ──────────────────────────────────────────────────────────────────────
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("admin/login.html", {"request": request, "error": None})
@router.post("/login")
def login(request: Request, username: str = Form(...), password: str = Form(...)):
if username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD:
request.session["admin_logged_in"] = True
return RedirectResponse(url="/admin/playlists", status_code=303)
return templates.TemplateResponse(
"admin/login.html", {"request": request, "error": "Usuario o contraseña incorrectos"}, status_code=401
)
@router.get("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/admin/login")
# ── Playlists ──────────────────────────────────────────────────────────────────
@router.get("/playlists", response_class=HTMLResponse)
def list_playlists(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": None, "success": None},
)
@router.post("/playlists")
def add_playlist(
request: Request,
spotify_url: str = Form(...),
db: Session = Depends(get_db),
):
_require_admin(request)
parsed = _extract_spotify_item(spotify_url)
if not parsed:
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": "URL no reconocida. Acepta links de Spotify de canciones, álbumes, artistas o playlists.", "success": None},
status_code=400,
)
spotify_id, spotify_type = parsed
existing = db.query(Playlist).filter(Playlist.spotify_id == spotify_id).first()
if existing:
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": "Este elemento ya está en el mantenedor", "success": None},
status_code=400,
)
try:
sp = spotify.get_client()
meta = _fetch_metadata(sp, spotify_id, spotify_type)
except Exception:
meta = {"name": spotify_id, "description": "", "image_url": ""}
item = Playlist(
name=meta["name"],
spotify_id=spotify_id,
spotify_type=spotify_type,
description=meta["description"],
image_url=meta["image_url"],
)
db.add(item)
db.commit()
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
label = _TYPE_LABELS.get(spotify_type, ("", spotify_type))[1]
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": None, "success": f'{label} "{meta["name"]}" agregado correctamente'},
)
@router.post("/playlists/{playlist_id}/edit")
def edit_playlist(
playlist_id: int,
request: Request,
name: str = Form(...),
description: str = Form(""),
emoji: str = Form(""),
db: Session = Depends(get_db),
):
_require_admin(request)
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Elemento no encontrado")
playlist.name = name.strip() or playlist.name
playlist.description = description.strip()
playlist.emoji = emoji.strip()
db.commit()
return RedirectResponse(url="/admin/playlists", status_code=303)
@router.post("/playlists/{playlist_id}/delete")
def delete_playlist(playlist_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist no encontrada")
db.delete(playlist)
db.commit()
return RedirectResponse(url="/admin/playlists", status_code=303)
# ── Votación ────────────────────────────────────────────────────────────────────
def _get_or_create_config(db: Session) -> VotingConfig:
config = db.query(VotingConfig).first()
if not config:
config = VotingConfig(start_time="12:00", end_time="13:00", is_active=False)
db.add(config)
db.commit()
db.refresh(config)
return config
def _vote_results(db: Session) -> list[dict]:
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
results = []
for pl in playlists:
count = db.query(Vote).filter(Vote.playlist_id == pl.id).count()
results.append({"playlist": pl, "votes": count})
results.sort(key=lambda x: x["votes"], reverse=True)
return results
@router.get("/voting", response_class=HTMLResponse)
def voting_admin(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
config = _get_or_create_config(db)
results = _vote_results(db)
total_votes = sum(r["votes"] for r in results)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": total_votes,
"error": None,
"success": None,
},
)
@router.post("/voting")
def update_voting_config(
request: Request,
start_time: str = Form(...),
end_time: str = Form(...),
is_active: str = Form(default="off"),
db: Session = Depends(get_db),
):
_require_admin(request)
config = _get_or_create_config(db)
try:
datetime.strptime(start_time, "%H:%M")
datetime.strptime(end_time, "%H:%M")
except ValueError:
results = _vote_results(db)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"error": "Formato de hora inválido. Use HH:MM",
"success": None,
},
status_code=400,
)
if start_time >= end_time:
results = _vote_results(db)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"error": "La hora de inicio debe ser anterior a la hora de fin",
"success": None,
},
status_code=400,
)
config.start_time = start_time
config.end_time = end_time
config.is_active = is_active == "on"
db.commit()
results = _vote_results(db)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"error": None,
"success": "Configuración guardada",
},
)
@router.post("/voting/reset")
def reset_votes(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
db.query(Vote).delete()
db.commit()
return RedirectResponse(url="/admin/voting", status_code=303)
@router.post("/voting/play-winner")
def play_winner(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
results = _vote_results(db)
if not results or results[0]["votes"] == 0:
raise HTTPException(status_code=400, detail="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))
return RedirectResponse(url="/admin/voting", status_code=303)
# ── Sistema ────────────────────────────────────────────────────────────────────
@router.post("/system/restart")
def system_restart(request: Request):
_require_admin(request)
def _do():
time.sleep(0.8)
os.execv(sys.executable, [sys.executable] + sys.argv)
threading.Thread(target=_do, daemon=True).start()
return templates.TemplateResponse(
"admin/system_action.html",
{"request": request, "action": "reiniciando", "message": "El servidor se está reiniciando…"},
)
@router.post("/system/shutdown")
def system_shutdown(request: Request):
_require_admin(request)
def _do():
time.sleep(0.8)
os.kill(os.getpid(), signal.SIGTERM)
threading.Thread(target=_do, daemon=True).start()
return templates.TemplateResponse(
"admin/system_action.html",
{"request": request, "action": "apagando", "message": "El servidor se está apagando…"},
)
+32
View File
@@ -0,0 +1,32 @@
import os
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from app import spotify
from app.config import settings
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/login")
def spotify_login():
return RedirectResponse(url=spotify.get_auth_url())
@router.get("/callback")
def spotify_callback(code: str):
spotify.exchange_code(code)
return RedirectResponse(url="/")
@router.get("/logout")
def spotify_logout():
try:
os.remove(".spotify_cache")
except FileNotFoundError:
pass
return RedirectResponse(url="/auth/login")
@router.get("/status")
def auth_status():
return {"authenticated": spotify.is_authenticated()}
+160
View File
@@ -0,0 +1,160 @@
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app import spotify
router = APIRouter(prefix="/player", tags=["player"])
class VolumeBody(BaseModel):
volume: int
class DeviceBody(BaseModel):
device_id: str
class PlayBody(BaseModel):
context_uri: str | None = None
uris: list[str] | None = None
device_id: str | None = None
def _sp():
return spotify.get_client()
@router.get("/current")
def current_track():
sp = _sp()
track = sp.current_playback()
if not track or not track.get("item"):
return {"playing": False}
item = track["item"]
artists = ", ".join(a["name"] for a in item["artists"])
album = item["album"]
image = album["images"][0]["url"] if album["images"] else ""
return {
"playing": track["is_playing"],
"track": item["name"],
"artists": artists,
"album": album["name"],
"image": image,
"progress_ms": track["progress_ms"],
"duration_ms": item["duration_ms"],
"volume": track["device"]["volume_percent"] if track.get("device") else None,
"device": track["device"]["name"] if track.get("device") else None,
"context_uri": track["context"]["uri"] if track.get("context") else None,
}
@router.get("/devices")
def get_devices():
sp = _sp()
result = sp.devices()
return result.get("devices", [])
@router.post("/play")
def play(body: PlayBody):
sp = _sp()
kwargs = {}
if body.uris:
kwargs["uris"] = body.uris
elif body.context_uri:
kwargs["context_uri"] = body.context_uri
if body.device_id:
kwargs["device_id"] = body.device_id
sp.start_playback(**kwargs)
return {"ok": True}
@router.post("/pause")
def pause():
sp = _sp()
sp.pause_playback()
return {"ok": True}
@router.post("/next")
def next_track():
sp = _sp()
sp.next_track()
return {"ok": True}
@router.post("/previous")
def previous_track():
sp = _sp()
sp.previous_track()
return {"ok": True}
@router.post("/volume")
def set_volume(body: VolumeBody):
if not 0 <= body.volume <= 100:
raise HTTPException(status_code=400, detail="El volumen debe estar entre 0 y 100")
sp = _sp()
sp.volume(body.volume)
return {"ok": True}
@router.post("/device")
def set_device(body: DeviceBody):
sp = _sp()
sp.transfer_playback(device_id=body.device_id, force_play=False)
return {"ok": True}
@router.get("/tracks")
def get_tracks(id: str = Query(...), type: str = Query(...)):
sp = _sp()
tracks = []
if type == "playlist":
results = sp.playlist_tracks(
id, limit=50,
fields="items(track(id,name,artists,duration_ms))",
)
for item in (results.get("items") or []):
t = item.get("track")
if t and t.get("id"):
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
elif type == "album":
results = sp.album_tracks(id, limit=50)
for t in (results.get("items") or []):
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
elif type == "artist":
results = sp.artist_top_tracks(id)
for t in (results.get("tracks") or []):
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
elif type == "track":
t = sp.track(id)
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
return tracks
+93
View File
@@ -0,0 +1,93 @@
from collections import Counter
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import PlayHistory
router = APIRouter(prefix="/stats", tags=["stats"])
templates = Jinja2Templates(directory="app/templates")
_TOP_N = 10
def _stats_for_day(db: Session, selected: date) -> dict:
records = (
db.query(PlayHistory)
.filter(func.date(PlayHistory.played_at) == selected.isoformat())
.order_by(PlayHistory.played_at.asc())
.all()
)
track_counts: Counter = Counter()
artist_counts: Counter = Counter()
genre_counts: Counter = Counter()
for r in records:
track_counts[(r.track_id, r.track_name)] += 1
for artist in (a.strip() for a in r.artists.split(",") if a.strip()):
artist_counts[artist] += 1
for genre in (g.strip() for g in r.genres.split(",") if g.strip()):
genre_counts[genre] += 1
def _ranked(counter: Counter) -> list[dict]:
top = counter.most_common(_TOP_N)
max_val = top[0][1] if top else 1
return [
{"label": k if isinstance(k, str) else k[1], "count": v, "pct": round(v / max_val * 100)}
for k, v in top
]
return {
"total_plays": len(records),
"top_tracks": _ranked(track_counts),
"top_artists": _ranked(artist_counts),
"top_genres": _ranked(genre_counts),
}
@router.get("/", response_class=HTMLResponse)
def stats_page(request: Request, day: str | None = None, db: Session = Depends(get_db)):
try:
selected_date = date.fromisoformat(day) if day else date.today()
except ValueError:
selected_date = date.today()
data = _stats_for_day(db, selected_date)
# Días con al menos una reproducción (para el selector)
days_with_data = [
str(r[0])
for r in db.query(func.date(PlayHistory.played_at))
.distinct()
.order_by(func.date(PlayHistory.played_at).desc())
.limit(30)
.all()
]
return templates.TemplateResponse(
"admin/stats.html",
{
"request": request,
"selected_date": selected_date.isoformat(),
"prev_date": (selected_date - timedelta(days=1)).isoformat(),
"next_date": (selected_date + timedelta(days=1)).isoformat(),
"is_today": selected_date == date.today(),
"days_with_data": days_with_data,
**data,
},
)
@router.get("/api")
def stats_api(day: str | None = None, db: Session = Depends(get_db)):
try:
selected_date = date.fromisoformat(day) if day else date.today()
except ValueError:
selected_date = date.today()
return {"date": selected_date.isoformat(), **_stats_for_day(db, selected_date)}
+117
View File
@@ -0,0 +1,117 @@
import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Playlist, Vote, VotingConfig
router = APIRouter(prefix="/voting", tags=["voting"])
COOLDOWN_SECONDS = 10
def _voter_token(request: Request) -> str:
token = request.session.get("voter_token")
if not token:
token = str(uuid.uuid4())
request.session["voter_token"] = token
return token
def _is_open(config: VotingConfig | None) -> bool:
if not config or not config.is_active:
return False
now = datetime.now().strftime("%H:%M")
return config.start_time <= now <= config.end_time
def _cooldown_remaining(db: Session, voter_token: str) -> int:
"""Segundos restantes de cooldown. 0 si puede votar."""
last = (
db.query(Vote)
.filter(Vote.voter_token == voter_token)
.order_by(Vote.voted_at.desc())
.first()
)
if not last:
return 0
voted_at = last.voted_at
if voted_at.tzinfo is None:
voted_at = voted_at.replace(tzinfo=timezone.utc)
elapsed = (datetime.now(timezone.utc) - voted_at).total_seconds()
return max(0, int(COOLDOWN_SECONDS - elapsed))
@router.get("/status")
def voting_status(request: Request, db: Session = Depends(get_db)):
config = db.query(VotingConfig).first()
voter_token = _voter_token(request)
is_open = _is_open(config)
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
vote_counts: dict[int, int] = {
pl.id: db.query(Vote).filter(Vote.playlist_id == pl.id).count()
for pl in playlists
}
last_vote = (
db.query(Vote)
.filter(Vote.voter_token == voter_token)
.order_by(Vote.voted_at.desc())
.first()
)
return {
"is_open": is_open,
"cooldown_seconds": COOLDOWN_SECONDS,
"cooldown_remaining": _cooldown_remaining(db, voter_token),
"config": {
"start_time": config.start_time if config else None,
"end_time": config.end_time if config else None,
"is_active": config.is_active if config else False,
},
"my_last_vote_playlist_id": last_vote.playlist_id if last_vote else None,
"playlists": [
{
"id": pl.id,
"spotify_id": pl.spotify_id,
"spotify_type": pl.spotify_type,
"name": pl.name,
"image_url": pl.image_url,
"emoji": pl.emoji or "",
"description": pl.description or "",
"votes": vote_counts.get(pl.id, 0),
}
for pl in playlists
],
}
@router.post("/vote/{playlist_id}")
def cast_vote(playlist_id: int, request: Request, db: Session = Depends(get_db)):
config = db.query(VotingConfig).first()
if not _is_open(config):
raise HTTPException(status_code=403, detail="La votación no está abierta en este momento")
pl = db.query(Playlist).filter(Playlist.id == playlist_id).first()
if not pl:
raise HTTPException(status_code=404, detail="Playlist no encontrada")
voter_token = _voter_token(request)
remaining = _cooldown_remaining(db, voter_token)
if remaining > 0:
raise HTTPException(
status_code=429,
detail={"message": "Debes esperar antes de votar de nuevo", "remaining": remaining},
)
db.add(Vote(
playlist_id=playlist_id,
voter_token=voter_token,
voted_at=datetime.now(timezone.utc),
))
db.commit()
return {"ok": True, "cooldown_seconds": COOLDOWN_SECONDS}
+38
View File
@@ -0,0 +1,38 @@
import spotipy
from spotipy.oauth2 import SpotifyOAuth
from fastapi import HTTPException
from app.config import settings
_oauth = SpotifyOAuth(
client_id=settings.SPOTIFY_CLIENT_ID,
client_secret=settings.SPOTIFY_CLIENT_SECRET,
redirect_uri=settings.SPOTIFY_REDIRECT_URI,
scope=settings.SPOTIFY_SCOPES,
cache_path=".spotify_cache",
open_browser=False,
)
def get_auth_url() -> str:
return _oauth.get_authorize_url()
def exchange_code(code: str) -> dict:
return _oauth.get_access_token(code, as_dict=True, check_cache=False)
def get_client() -> spotipy.Spotify:
token_info = _oauth.get_cached_token()
if not token_info:
raise HTTPException(status_code=401, detail="spotify_not_authenticated")
if _oauth.is_token_expired(token_info):
token_info = _oauth.refresh_access_token(token_info["refresh_token"])
return spotipy.Spotify(auth=token_info["access_token"])
def is_authenticated() -> bool:
try:
token_info = _oauth.get_cached_token()
return token_info is not None
except Exception:
return False
+33
View File
@@ -0,0 +1,33 @@
<div class="system-card">
<span class="system-label">Servidor</span>
<form method="post" action="/admin/system/restart"
onsubmit="return confirm('¿Reiniciar el servidor?')">
<button type="submit" class="btn-sm btn-system">🔄 Reiniciar</button>
</form>
<form method="post" action="/admin/system/shutdown"
onsubmit="return confirm('¿Apagar el servidor? Deberás iniciarlo manualmente.')">
<button type="submit" class="btn-sm btn-system btn-danger">⏹ Apagar</button>
</form>
</div>
<style>
.system-card {
display: flex;
align-items: center;
gap: .6rem;
padding: .65rem 1rem;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid #2a2a2a;
margin-top: .5rem;
}
.system-label {
font-size: .75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .5px;
margin-right: .25rem;
}
.btn-system { font-size: .82rem; }
</style>
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Cantina</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-container">
<h1>🔐 Admin</h1>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/login" class="login-form">
<label>Usuario</label>
<input type="text" name="username" class="input" autofocus required>
<label>Contraseña</label>
<input type="password" name="password" class="input" required>
<button type="submit" class="btn-primary">Ingresar</button>
</form>
<a href="/" class="back-link">← Volver al reproductor</a>
</div>
</body>
</html>
+197
View File
@@ -0,0 +1,197 @@
{% extends "base.html" %}
{% block title %}Mantenedor — Cantina{% endblock %}
{% block content %}
<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>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<!-- Formulario para agregar -->
<div class="card">
<h2>Agregar elemento</h2>
<form method="post" action="/admin/playlists" class="add-form">
<div class="form-row">
<input
type="text"
name="spotify_url"
class="input"
placeholder="URL de Spotify (canción, álbum, artista o playlist)"
required
>
<button type="submit" class="btn-primary">Agregar</button>
</div>
<small class="hint">
Acepta cualquier URL de <strong>open.spotify.com</strong>,
URI (<code>spotify:track:…</code>, <code>spotify:album:…</code>, etc.)
o ID de 22 caracteres (se asume playlist).
</small>
</form>
</div>
<!-- Lista -->
<div class="card">
<h2>Elementos configurados ({{ playlists | length }})</h2>
{% if playlists %}
<table class="table">
<thead>
<tr>
<th></th>
<th>Nombre</th>
<th>Tipo</th>
<th>Descripción</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for pl in playlists %}
{% set type_info = type_labels.get(pl.spotify_type, ('🎵', pl.spotify_type)) %}
<tr>
<td>
{% if pl.emoji %}
<div class="table-thumb-placeholder emoji-thumb">{{ pl.emoji }}</div>
{% elif pl.image_url %}
<img src="{{ pl.image_url }}" alt="" class="table-thumb">
{% else %}
<div class="table-thumb-placeholder">{{ type_info[0] }}</div>
{% endif %}
</td>
<td class="td-name">{{ pl.name }}</td>
<td>
<span class="type-badge type-{{ pl.spotify_type }}">
{{ type_info[0] }} {{ type_info[1] }}
</span>
</td>
<td class="td-desc">{{ pl.description[:80] }}{% if pl.description | length > 80 %}…{% endif %}</td>
<td class="td-actions">
<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 }}?')">
<button type="submit" class="btn-sm btn-danger">Eliminar</button>
</form>
</td>
</tr>
<!-- Fila de edición inline -->
<tr id="edit-row-{{ pl.id }}" class="edit-row" style="display:none">
<td colspan="5">
<form method="post" action="/admin/playlists/{{ pl.id }}/edit" class="edit-form">
<div class="edit-fields">
<div class="edit-field">
<label>Nombre</label>
<input type="text" name="name" class="input"
value="{{ pl.name }}" required maxlength="200">
</div>
<div class="edit-field edit-field-desc">
<label>Descripción</label>
<input type="text" name="description" class="input"
value="{{ pl.description }}" maxlength="300">
</div>
<div class="edit-field edit-field-emoji">
<label>Emoticono <span class="hint-inline">(reemplaza imagen)</span></label>
<div class="emoji-input-wrap">
<input type="text" name="emoji" id="emoji-{{ pl.id }}" class="input emoji-input"
value="{{ pl.emoji }}" maxlength="8" placeholder="😀">
{% if pl.emoji %}
<button type="button" class="btn-sm emoji-clear"
onclick="clearEmoji({{ pl.id }})"></button>
{% endif %}
</div>
<div class="emoji-picker">
{% for e in ['🎵','🎶','🎸','🎹','🎺','🎻','🥁','🎤','🎧','🎼','🎷','🪗','🪘','🔥','⚡','🌟','✨','💫','🌈','🎉','🥳','🍕','🍺','🍹','🌙','☀️','🏖️','🌊','💃','🕺','🎭','🎬','🎮','🏆','❤️','🤘'] %}
<button type="button" class="emoji-opt"
onclick="pickEmoji({{ pl.id }}, '{{ e }}')">{{ e }}</button>
{% endfor %}
</div>
</div>
</div>
<div class="edit-actions">
<button type="submit" class="btn-primary btn-sm">Guardar</button>
<button type="button" class="btn-sm" onclick="toggleEdit({{ pl.id }})">Cancelar</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-msg">No hay elementos configurados todavía.</p>
{% endif %}
</div>
{% include "admin/_system_buttons.html" %}
</div>
<script>
function toggleEdit(id) {
const row = document.getElementById('edit-row-' + id);
const visible = row.style.display !== 'none';
row.style.display = visible ? 'none' : 'table-row';
if (!visible) row.querySelector('input[name="name"]').focus();
}
function pickEmoji(id, emoji) {
document.getElementById('emoji-' + id).value = emoji;
}
function clearEmoji(id) {
document.getElementById('emoji-' + id).value = '';
}
</script>
<style>
.td-actions { display: flex; gap: .4rem; align-items: center; }
.edit-row td { background: var(--surface2); padding: .75rem 1rem; }
.edit-form { display: flex; flex-direction: column; gap: .75rem; }
.edit-fields { display: flex; gap: .6rem; flex-wrap: wrap; align-items: flex-start; }
.edit-field { display: flex; flex-direction: column; gap: .25rem; min-width: 140px; }
.edit-field-desc { flex: 2; }
.edit-field-emoji { min-width: 180px; }
.edit-field label { font-size: .75rem; color: var(--text-muted); }
.hint-inline { font-weight: 400; opacity: .6; }
.edit-actions { display: flex; gap: .4rem; align-items: center; }
.emoji-input-wrap { display: flex; gap: .35rem; align-items: center; }
.emoji-input { width: 70px; font-size: 1.2rem; text-align: center; padding: .3rem .4rem; }
.emoji-clear { padding: .3rem .5rem; line-height: 1; }
.emoji-picker { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .35rem; }
.emoji-opt {
background: var(--surface);
border: 1px solid #333;
border-radius: 6px;
font-size: 1.15rem;
width: 34px; height: 34px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .12s, transform .1s;
}
.emoji-opt:hover { background: #333; transform: scale(1.15); }
.emoji-thumb { font-size: 1.6rem; }
.type-badge {
display: inline-flex;
align-items: center;
gap: .3rem;
font-size: .75rem;
padding: .2rem .55rem;
border-radius: 20px;
font-weight: 500;
white-space: nowrap;
}
.type-playlist { background: rgba(29,185,84,.12); color: #1db954; border: 1px solid rgba(29,185,84,.3); }
.type-album { background: rgba(168,85,247,.12); color: #a855f7; border: 1px solid rgba(168,85,247,.3); }
.type-artist { background: rgba(59,130,246,.12); color: #3b82f6; border: 1px solid rgba(59,130,246,.3); }
.type-track { background: rgba(245,158,11,.12); color: #f59e0b; border: 1px solid rgba(245,158,11,.3); }
</style>
{% endblock %}
+207
View File
@@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title %}Estadísticas — Cantina{% endblock %}
{% block content %}
<div class="admin-container">
<!-- Encabezado -->
<div class="admin-header">
<h1>Estadísticas de reproducción</h1>
</div>
<!-- Selector de fecha -->
<div class="card date-nav">
<a href="/stats/?day={{ prev_date }}" class="btn-sm">← Día anterior</a>
<form method="get" action="/stats/" class="date-form">
<input type="date" name="day" value="{{ selected_date }}"
max="{{ selected_date if is_today else '' }}"
class="input date-input" onchange="this.form.submit()">
</form>
{% if not is_today %}
<a href="/stats/?day={{ next_date }}" class="btn-sm">Día siguiente →</a>
{% else %}
<span class="btn-sm" style="opacity:.4;cursor:default">Día siguiente →</span>
{% endif %}
<span class="total-badge">{{ total_plays }} reproduccion{{ 'es' if total_plays != 1 else '' }}</span>
</div>
{% if total_plays == 0 %}
<div class="card">
<p class="empty-msg" style="text-align:center;padding:2rem 0">
Sin reproducciones registradas para este día.<br>
<small>El historial se carga automáticamente desde Spotify cada 30 segundos.</small>
</p>
</div>
{% else %}
<div class="stats-grid">
<!-- Top Canciones -->
<div class="card">
<h2>🎵 Canciones más escuchadas</h2>
<div class="chart">
{% for item in top_tracks %}
<div class="chart-row">
<span class="rank">{{ loop.index }}</span>
<div class="chart-info">
<span class="chart-label">{{ item.label }}</span>
<div class="bar-wrap">
<div class="bar" style="width:{{ item.pct }}%"></div>
</div>
</div>
<span class="chart-count">{{ item.count }}x</span>
</div>
{% else %}
<p class="empty-msg">Sin datos</p>
{% endfor %}
</div>
</div>
<!-- Top Artistas -->
<div class="card">
<h2>🎤 Artistas más escuchados</h2>
<div class="chart">
{% for item in top_artists %}
<div class="chart-row">
<span class="rank">{{ loop.index }}</span>
<div class="chart-info">
<span class="chart-label">{{ item.label }}</span>
<div class="bar-wrap">
<div class="bar bar-artist" style="width:{{ item.pct }}%"></div>
</div>
</div>
<span class="chart-count">{{ item.count }}x</span>
</div>
{% else %}
<p class="empty-msg">Sin datos</p>
{% endfor %}
</div>
</div>
<!-- Top Géneros -->
<div class="card" style="grid-column: 1 / -1">
<h2>🎸 Géneros más escuchados</h2>
{% if top_genres %}
<div class="genre-bars">
{% for item in top_genres %}
<div class="genre-row">
<span class="genre-name">{{ item.label }}</span>
<div class="bar-wrap genre-bar-wrap">
<div class="bar bar-genre" style="width:{{ item.pct }}%">
<span class="bar-label-inside">{{ item.count }}x</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-msg">Sin información de géneros (los géneros se obtienen desde la API de artistas de Spotify).</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
<style>
.date-nav {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
.date-form { display: flex; }
.date-input { width: auto; min-width: 160px; }
.total-badge {
margin-left: auto;
background: rgba(29,185,84,.12);
color: var(--green);
border: 1px solid rgba(29,185,84,.3);
border-radius: 20px;
padding: .3rem .9rem;
font-size: .82rem;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 700px) {
.stats-grid { grid-template-columns: 1fr; }
}
.chart { display: flex; flex-direction: column; gap: .55rem; margin-top: .25rem; }
.chart-row {
display: flex;
align-items: center;
gap: .6rem;
}
.rank {
width: 18px;
text-align: right;
font-size: .78rem;
color: var(--text-muted);
font-weight: 700;
flex-shrink: 0;
}
.chart-info { flex: 1; display: flex; flex-direction: column; gap: .2rem; overflow: hidden; }
.chart-label {
font-size: .85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-wrap {
height: 6px;
background: #2a2a2a;
border-radius: 3px;
overflow: hidden;
}
.bar {
height: 100%;
background: var(--green);
border-radius: 3px;
transition: width .5s ease;
}
.bar-artist { background: #a855f7; }
.chart-count {
font-size: .78rem;
color: var(--text-muted);
min-width: 28px;
text-align: right;
}
/* Géneros — barras horizontales grandes */
.genre-bars { display: flex; flex-direction: column; gap: .55rem; margin-top: .5rem; }
.genre-row { display: flex; align-items: center; gap: .75rem; }
.genre-name {
width: 160px;
font-size: .85rem;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-muted);
flex-shrink: 0;
}
.genre-bar-wrap { flex: 1; height: 22px; background: #2a2a2a; border-radius: 4px; overflow: hidden; }
.bar-genre {
height: 100%;
background: #f59e0b;
border-radius: 4px;
display: flex;
align-items: center;
transition: width .5s ease;
min-width: 36px;
}
.bar-label-inside { font-size: .72rem; font-weight: 600; color: #000; padding: 0 8px; white-space: nowrap; }
</style>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ action | capitalize }} — Cantina</title>
<link rel="stylesheet" href="/static/style.css">
{% if action == "reiniciando" %}
<meta http-equiv="refresh" content="4;url=/admin/playlists">
{% endif %}
</head>
<body>
<div class="system-action-container">
<div class="system-action-icon">
{% if action == "reiniciando" %}🔄{% else %}⏹{% endif %}
</div>
<h1>{{ message }}</h1>
{% if action == "reiniciando" %}
<p class="system-action-sub">Redirigiendo en unos segundos…</p>
<div class="spinner"></div>
{% else %}
<p class="system-action-sub">Puedes cerrar esta ventana.</p>
{% endif %}
</div>
<style>
.system-action-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 1rem;
text-align: center;
padding: 2rem;
}
.system-action-icon { font-size: 3.5rem; }
h1 { font-size: 1.4rem; font-weight: 600; }
.system-action-sub { color: var(--text-muted); font-size: .9rem; }
.spinner {
width: 36px;
height: 36px;
border: 3px solid #333;
border-top-color: var(--green);
border-radius: 50%;
animation: spin .8s linear infinite;
margin-top: .5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</body>
</html>
+166
View File
@@ -0,0 +1,166 @@
{% extends "base.html" %}
{% block title %}Votación — Admin{% endblock %}
{% block content %}
<div class="admin-container">
<div class="admin-header">
<h1>Control de Votación</h1>
<div style="display:flex;gap:.5rem">
<a href="/admin/playlists" class="btn-sm">Playlists</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 %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<!-- Configuración de rango horario -->
<div class="card">
<h2>Configurar votación</h2>
<form method="post" action="/admin/voting" class="voting-form">
<div class="time-row">
<div class="time-field">
<label>Hora de inicio</label>
<input type="time" name="start_time" class="input"
value="{{ config.start_time }}" required>
</div>
<div class="time-sep"></div>
<div class="time-field">
<label>Hora de fin</label>
<input type="time" name="end_time" class="input"
value="{{ config.end_time }}" required>
</div>
</div>
<label class="toggle-label">
<input type="checkbox" name="is_active"
{% if config.is_active %}checked{% endif %}>
<span class="toggle-text">Votación activa</span>
<span class="toggle-hint">
{% if config.is_active %}
Los usuarios pueden votar dentro del rango horario
{% else %}
La votación está desactivada
{% endif %}
</span>
</label>
<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>
</div>
<!-- Resultados -->
<div class="card">
<div class="results-header">
<h2>Resultados ({{ total_votes }} votos)</h2>
<div style="display:flex;gap:.5rem;align-items:center">
{% if results and results[0].votes > 0 %}
<form method="post" action="/admin/voting/play-winner">
<button type="submit" class="btn-primary btn-sm">
▶ Reproducir ganador
</button>
</form>
{% endif %}
<form method="post" action="/admin/voting/reset"
onsubmit="return confirm('¿Reiniciar todos los votos?')">
<button type="submit" class="btn-sm btn-danger">Reiniciar votos</button>
</form>
</div>
</div>
{% if results %}
<div class="results-list">
{% for item in results %}
{% set pct = (item.votes / total_votes * 100) | round(1) if total_votes > 0 else 0 %}
<div class="result-row {% if loop.first and item.votes > 0 %}result-winner{% endif %}">
<div class="result-rank">{{ loop.index }}</div>
{% if item.playlist.image_url %}
<img src="{{ item.playlist.image_url }}" alt="" class="table-thumb">
{% else %}
<div class="table-thumb-placeholder">🎵</div>
{% endif %}
<div class="result-info">
<div class="result-name">
{{ item.playlist.name }}
{% if loop.first and item.votes > 0 %}
<span class="winner-badge">🏆 Ganador</span>
{% endif %}
</div>
<div class="result-bar-wrap">
<div class="result-bar" style="width: {{ pct }}%"></div>
</div>
</div>
<div class="result-count">
<strong>{{ item.votes }}</strong>
<span class="pct-label">{{ pct }}%</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-msg">No hay playlists configuradas.</p>
{% endif %}
</div>
</div>
<style>
.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; }
.time-field label { font-size: .8rem; color: var(--text-muted); }
.time-sep { padding-bottom: .6rem; color: var(--text-muted); }
.toggle-label { display: flex; align-items: center; gap: .6rem; cursor: pointer; }
.toggle-label input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--green); }
.toggle-text { font-weight: 600; font-size: .9rem; }
.toggle-hint { font-size: .8rem; color: var(--text-muted); }
.status-badge {
display: inline-flex;
padding: .4rem .8rem;
border-radius: 20px;
font-size: .82rem;
font-weight: 500;
align-self: flex-start;
}
.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; }
.results-header { display: flex; justify-content: space-between; align-items: center; }
.results-list { display: flex; flex-direction: column; gap: .5rem; margin-top: .25rem; }
.result-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .6rem;
border-radius: 8px;
background: var(--surface2);
}
.result-winner { border: 1px solid rgba(29,185,84,.4); background: rgba(29,185,84,.06); }
.result-rank { width: 20px; text-align: center; font-size: .82rem; color: var(--text-muted); font-weight: 600; }
.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 { 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; }
.pct-label { font-size: .75rem; color: var(--text-muted); }
</style>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Spotify Cantina{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar">
<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>
</div>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+588
View File
@@ -0,0 +1,588 @@
{% extends "base.html" %}
{% block title %}Reproductor — Cantina{% endblock %}
{% block content %}
<div class="player-container">
<!-- Banner de votación (visible solo cuando está abierta) -->
<div id="voting-banner" class="voting-banner" style="display:none">
<span id="voting-banner-text">🗳️ Votación abierta</span>
</div>
<!-- Información de la canción actual -->
<div class="now-playing">
<img id="album-art" src="/static/placeholder.svg" alt="Álbum" class="album-art">
<div class="track-info">
<div id="track-name" class="track-name"></div>
<div id="artists" class="artists"></div>
<div id="album-name" class="album-label"></div>
<div id="device-name" class="device-label">Sin dispositivo activo</div>
</div>
</div>
<!-- Barra de progreso -->
<div class="progress-bar-container">
<span id="progress-time" class="time-label">0:00</span>
<div class="progress-bar-bg">
<div id="progress-bar" class="progress-bar-fill" style="width: 0%"></div>
</div>
<span id="duration-time" class="time-label">0:00</span>
</div>
<!-- Controles de reproducción -->
<div class="controls">
<button class="btn-control" onclick="prevTrack()" title="Anterior">&#9664;&#9664;</button>
<button class="btn-play" id="btn-play" onclick="togglePlay()">&#9654;</button>
<button class="btn-control" onclick="nextTrack()" title="Siguiente">&#9654;&#9654;</button>
</div>
<!-- Volumen -->
<div class="volume-row">
<span class="volume-icon">🔈</span>
<input type="range" id="volume-slider" min="0" max="100" value="50"
oninput="setVolume(this.value)" class="volume-slider">
<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">
<label class="section-label" id="playlist-section-label">Playlists</label>
</div>
<div id="playlist-grid" class="playlist-grid">
<p class="empty-msg">Cargando...</p>
</div>
</div>
</div>
<!-- Modal de canciones -->
<div id="tracks-modal" class="modal-overlay" style="display:none" onclick="closeTracksModal(event)">
<div class="modal-box">
<div class="modal-header">
<div class="modal-title-wrap">
<div id="modal-thumb"></div>
<div>
<div id="modal-title" class="modal-title"></div>
<div id="modal-subtitle" class="modal-subtitle"></div>
</div>
</div>
<button class="modal-close" onclick="closeTracksModal()"></button>
</div>
<div id="modal-track-list" class="modal-track-list">
<p class="empty-msg">Cargando...</p>
</div>
</div>
</div>
<script>
let isPlaying = false;
let currentDeviceId = null;
let votingOpen = false;
let myVotedPlaylistId = null;
let cooldownRemaining = 0;
let cooldownTimer = null;
function msToTime(ms) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
// ── Reproductor ──────────────────────────────────────────────────────────────
async function fetchCurrent() {
try {
const res = await fetch('/player/current');
if (res.status === 401) { window.location.href = '/auth/login'; return; }
const data = await res.json();
isPlaying = data.playing;
document.getElementById('btn-play').innerHTML = isPlaying ? '&#9646;&#9646;' : '&#9654;';
if (data.track) {
document.getElementById('track-name').textContent = data.track;
document.getElementById('artists').textContent = data.artists;
document.getElementById('album-name').textContent = data.album;
document.getElementById('album-art').src = data.image || '/static/placeholder.svg';
document.getElementById('device-name').textContent = data.device ? `🔊 ${data.device}` : 'Sin dispositivo activo';
const pct = data.duration_ms > 0 ? (data.progress_ms / data.duration_ms) * 100 : 0;
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-time').textContent = msToTime(data.progress_ms);
document.getElementById('duration-time').textContent = msToTime(data.duration_ms);
if (data.volume !== null) document.getElementById('volume-slider').value = data.volume;
}
} catch (_) {}
}
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 });
setTimeout(fetchCurrent, 300);
}
async function nextTrack() {
await fetch('/player/next', { method: 'POST' });
setTimeout(fetchCurrent, 300);
}
async function prevTrack() {
await fetch('/player/previous', { method: 'POST' });
setTimeout(fetchCurrent, 300);
}
let volumeTimer = null;
function setVolume(val) {
clearTimeout(volumeTimer);
volumeTimer = setTimeout(async () => {
await fetch('/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume: parseInt(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 = {};
if (spotifyType === 'track') {
body.uris = [uri];
} else {
body.context_uri = uri;
}
if (currentDeviceId) body.device_id = currentDeviceId;
await fetch('/player/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setTimeout(fetchCurrent, 500);
}
// ── Votación ─────────────────────────────────────────────────────────────────
function startCooldownTick(seconds) {
clearInterval(cooldownTimer);
cooldownRemaining = seconds;
_updateCooldownUI();
if (seconds <= 0) return;
cooldownTimer = setInterval(() => {
cooldownRemaining--;
_updateCooldownUI();
if (cooldownRemaining <= 0) clearInterval(cooldownTimer);
}, 1000);
}
function _updateCooldownUI() {
const cards = document.querySelectorAll('.voting-card');
cards.forEach(card => {
const btn = card.querySelector('.vote-btn');
if (!btn) return;
if (cooldownRemaining > 0) {
btn.disabled = true;
btn.textContent = `${cooldownRemaining}s`;
} else {
btn.disabled = false;
btn.textContent = '👍 Votar';
}
});
}
async function castVote(playlistId) {
if (cooldownRemaining > 0) return;
const res = await fetch(`/voting/vote/${playlistId}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
myVotedPlaylistId = playlistId;
startCooldownTick(data.cooldown_seconds);
await fetchVotingStatus();
} else if (res.status === 429) {
const err = await res.json();
startCooldownTick(err.detail.remaining);
}
}
async function fetchVotingStatus() {
try {
const res = await fetch('/voting/status');
const data = await res.json();
votingOpen = data.is_open;
myVotedPlaylistId = data.my_last_vote_playlist_id;
// Solo sincronizar cooldown desde servidor si no hay timer local activo
if (cooldownRemaining <= 0 && data.cooldown_remaining > 0) {
startCooldownTick(data.cooldown_remaining);
}
const banner = document.getElementById('voting-banner');
const label = document.getElementById('playlist-section-label');
if (data.is_open) {
banner.style.display = 'flex';
document.getElementById('voting-banner-text').textContent =
`🗳️ Votación abierta · ${data.config.start_time} ${data.config.end_time}`;
label.textContent = 'Votar por una playlist';
} else {
banner.style.display = 'none';
label.textContent = 'Playlists';
}
renderPlaylists(data.playlists, data.is_open, data.my_last_vote_playlist_id);
} catch (_) {}
}
function renderPlaylists(playlists, isVoting, myVote) {
const grid = document.getElementById('playlist-grid');
if (!playlists || playlists.length === 0) {
grid.innerHTML = '<p class="empty-msg">No hay playlists configuradas. El administrador puede agregar en <a href="/admin/playlists">Admin</a>.</p>';
return;
}
const totalVotes = playlists.reduce((s, p) => s + p.votes, 0);
grid.innerHTML = playlists.map(pl => {
const isVoted = pl.id === myVote;
const pct = totalVotes > 0 ? Math.round(pl.votes / totalVotes * 100) : 0;
const img = pl.emoji
? `<div class="playlist-thumb-placeholder playlist-thumb-emoji">${pl.emoji}</div>`
: pl.image_url
? `<img src="${pl.image_url}" alt="${pl.name}" class="playlist-thumb">`
: `<div class="playlist-thumb-placeholder">🎵</div>`;
if (isVoting) {
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>
<button class="vote-btn" ${btnDisabled} onclick="castVote(${pl.id})">${btnLabel}</button>
</div>`;
} else {
return `
<div class="playlist-card clickable" onclick='openTracksModal(${JSON.stringify(pl)})'>
${img}
<div class="playlist-card-name">${pl.name}</div>
</div>`;
}
}).join('');
}
// ── Modal de canciones ────────────────────────────────────────────────────────
let _modalPlaylist = null;
async function openTracksModal(pl) {
_modalPlaylist = pl;
const modal = document.getElementById('tracks-modal');
const list = document.getElementById('modal-track-list');
const title = document.getElementById('modal-title');
const sub = document.getElementById('modal-subtitle');
const thumb = document.getElementById('modal-thumb');
title.textContent = pl.name;
const typeLabel = pl.spotify_type === 'artist' ? 'Canciones más populares' : '';
sub.textContent = pl.description || typeLabel;
thumb.innerHTML = pl.emoji
? `<div class="playlist-thumb-placeholder playlist-thumb-emoji modal-thumb-emoji">${pl.emoji}</div>`
: pl.image_url
? `<img src="${pl.image_url}" class="modal-thumb-img" alt="">`
: `<div class="playlist-thumb-placeholder modal-thumb-emoji">🎵</div>`;
list.innerHTML = '<p class="empty-msg">Cargando...</p>';
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
try {
const res = await fetch(`/player/tracks?id=${pl.spotify_id}&type=${pl.spotify_type}`);
if (!res.ok) throw new Error();
const tracks = await res.json();
if (!tracks.length) {
list.innerHTML = '<p class="empty-msg">Sin canciones disponibles.</p>';
return;
}
list.innerHTML = tracks.map((t, i) => `
<div class="modal-track-row" onclick="playTrackFromModal('${t.id}')">
<span class="modal-track-num">${i + 1}</span>
<div class="modal-track-info">
<div class="modal-track-name">${t.name}</div>
<div class="modal-track-artists">${t.artists}</div>
</div>
<span class="modal-track-dur">${msToTime(t.duration_ms)}</span>
</div>`).join('');
} catch (_) {
const msgs = [
'¿Y si simplemente la ponemos y ya? 🎶',
'Las canciones son un misterio... ponla y descúbrelas. 🎵',
'No hay preview, pero la vibra se siente. Dale play. ✨',
'Confía en el proceso. Y en la playlist. 🙌',
'A veces hay que tirar pa\'lante sin leer el menú. 🍽️',
'No sabemos qué hay adentro, pero suena bien. 🔊',
'¿Qué es la vida sin un poco de sorpresa musical? 🎲',
];
const msg = msgs[Math.floor(Math.random() * msgs.length)];
list.innerHTML = `<p class="empty-msg">${msg}</p>`;
}
}
function closeTracksModal(event) {
if (event && event.target !== document.getElementById('tracks-modal')) return;
document.getElementById('tracks-modal').style.display = 'none';
document.body.style.overflow = '';
}
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' },
body: JSON.stringify(body),
});
setTimeout(fetchCurrent, 400);
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeTracksModal();
});
loadDevices();
fetchCurrent();
fetchVotingStatus();
setInterval(fetchCurrent, 3000);
setInterval(fetchVotingStatus, 5000);
</script>
<style>
.voting-banner {
display: flex;
align-items: center;
justify-content: center;
background: rgba(29,185,84,.12);
border: 1px solid rgba(29,185,84,.35);
border-radius: 8px;
padding: .6rem 1rem;
font-size: .88rem;
font-weight: 500;
color: var(--green);
gap: .5rem;
}
.voting-card { cursor: pointer; position: relative; }
.voting-card.voted { border: 2px solid var(--green); }
.vote-bar-wrap {
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
overflow: hidden;
margin-top: 2px;
}
.vote-bar { height: 100%; background: var(--green); border-radius: 2px; transition: width .4s; }
.vote-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
font-size: .72rem;
color: var(--text-muted);
}
.voted-badge {
color: var(--green);
font-weight: 600;
}
.playlist-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.vote-btn {
width: 100%;
margin-top: 4px;
padding: .35rem 0;
background: var(--green);
color: #000;
font-weight: 700;
font-size: .78rem;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background .15s, opacity .15s;
}
.vote-btn:hover:not(:disabled) { background: var(--green-dark); }
.vote-btn:disabled {
background: #333;
color: var(--text-muted);
cursor: not-allowed;
opacity: .8;
}
/* ── Modal canciones ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal-box {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
width: 100%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem;
border-bottom: 1px solid #2a2a2a;
gap: .8rem;
}
.modal-title-wrap {
display: flex;
align-items: center;
gap: .8rem;
min-width: 0;
}
.modal-thumb-img {
width: 52px;
height: 52px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.modal-thumb-emoji {
width: 52px !important;
height: 52px !important;
font-size: 1.6rem !important;
flex-shrink: 0;
}
.modal-title {
font-size: 1rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-subtitle {
font-size: .78rem;
color: var(--text-muted);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: .2rem .4rem;
border-radius: 4px;
flex-shrink: 0;
}
.modal-close:hover { color: #fff; background: #2a2a2a; }
.modal-track-list {
overflow-y: auto;
padding: .4rem 0;
}
.modal-track-row {
display: flex;
align-items: center;
gap: .8rem;
padding: .55rem 1.2rem;
cursor: pointer;
transition: background .12s;
}
.modal-track-row:hover { background: #2a2a2a; }
.modal-track-num {
font-size: .8rem;
color: var(--text-muted);
width: 1.4rem;
text-align: right;
flex-shrink: 0;
}
.modal-track-info {
flex: 1;
min-width: 0;
}
.modal-track-name {
font-size: .88rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-track-artists {
font-size: .75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-track-dur {
font-size: .78rem;
color: var(--text-muted);
flex-shrink: 0;
}
</style>
{% endblock %}