commit inicial
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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})
|
||||
@@ -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"),)
|
||||
@@ -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…"},
|
||||
)
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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">◀◀</button>
|
||||
<button class="btn-play" id="btn-play" onclick="togglePlay()">▶</button>
|
||||
<button class="btn-control" onclick="nextTrack()" title="Siguiente">▶▶</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 ? '▮▮' : '▶';
|
||||
|
||||
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 %}
|
||||
Reference in New Issue
Block a user