commit a1b9c0139d3cb03ca5cf2541b5caa38a4f322e40 Author: David Inostroza Date: Thu Apr 23 00:39:58 2026 -0400 commit inicial diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..f8659eb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(pip3 install *)", + "Bash(python3 -m venv .venv)", + "Bash(.venv/bin/pip install *)", + "Bash(.venv/bin/python *)", + "Bash(git -C /home/deivid/spotify-cantina status)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cdad1d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.env +.venv +venv +__pycache__ +*.pyc +*.pyo +.spotify_cache +cantina.db +cert.pem +key.pem +.claude diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79ba6aa --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +SPOTIFY_CLIENT_ID=tu_client_id_aqui +SPOTIFY_CLIENT_SECRET=tu_client_secret_aqui +SPOTIFY_REDIRECT_URI=http://127.0.0.1:8000/auth/callback + +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +SECRET_KEY=cambia-esta-clave-secreta diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..745d7a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.spotify_cache +cantina.db +__pycache__/ +*.pyc +.venv/ +venv/ +cert.pem +key.pem diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81c8875 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ app/ +COPY static/ static/ + +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..2a34223 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..ba49671 --- /dev/null +++ b/app/database.py @@ -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 diff --git a/app/history_poller.py b/app/history_poller.py new file mode 100644 index 0000000..765ecfb --- /dev/null +++ b/app/history_poller.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8074bb9 --- /dev/null +++ b/app/main.py @@ -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}) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e2c441c --- /dev/null +++ b/app/models.py @@ -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"),) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..bd189b7 --- /dev/null +++ b/app/routers/admin.py @@ -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…"}, + ) diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..59cbc96 --- /dev/null +++ b/app/routers/auth.py @@ -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()} diff --git a/app/routers/player.py b/app/routers/player.py new file mode 100644 index 0000000..43ac1bc --- /dev/null +++ b/app/routers/player.py @@ -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 diff --git a/app/routers/stats.py b/app/routers/stats.py new file mode 100644 index 0000000..332d0c3 --- /dev/null +++ b/app/routers/stats.py @@ -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)} diff --git a/app/routers/voting.py b/app/routers/voting.py new file mode 100644 index 0000000..29d44e1 --- /dev/null +++ b/app/routers/voting.py @@ -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} diff --git a/app/spotify.py b/app/spotify.py new file mode 100644 index 0000000..02229db --- /dev/null +++ b/app/spotify.py @@ -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 diff --git a/app/templates/admin/_system_buttons.html b/app/templates/admin/_system_buttons.html new file mode 100644 index 0000000..ac58720 --- /dev/null +++ b/app/templates/admin/_system_buttons.html @@ -0,0 +1,33 @@ +
+ Servidor +
+ +
+
+ +
+
+ + diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html new file mode 100644 index 0000000..2b4fcde --- /dev/null +++ b/app/templates/admin/login.html @@ -0,0 +1,25 @@ + + + + + + Admin — Cantina + + + +
+

🔐 Admin

+ {% if error %} +
{{ error }}
+ {% endif %} + + ← Volver al reproductor +
+ + diff --git a/app/templates/admin/playlists.html b/app/templates/admin/playlists.html new file mode 100644 index 0000000..9288c8d --- /dev/null +++ b/app/templates/admin/playlists.html @@ -0,0 +1,197 @@ +{% extends "base.html" %} +{% block title %}Mantenedor — Cantina{% endblock %} + +{% block content %} +
+
+

Mantenedor

+ ⏏ Cuenta Spotify + Cerrar sesión +
+ + {% if error %} +
{{ error }}
+ {% endif %} + {% if success %} +
{{ success }}
+ {% endif %} + + +
+

Agregar elemento

+
+
+ + +
+ + Acepta cualquier URL de open.spotify.com, + URI (spotify:track:…, spotify:album:…, etc.) + o ID de 22 caracteres (se asume playlist). + +
+
+ + +
+

Elementos configurados ({{ playlists | length }})

+ {% if playlists %} + + + + + + + + + + + + {% for pl in playlists %} + {% set type_info = type_labels.get(pl.spotify_type, ('🎵', pl.spotify_type)) %} + + + + + + + + + + + + {% endfor %} + +
NombreTipoDescripciónAcciones
+ {% if pl.emoji %} +
{{ pl.emoji }}
+ {% elif pl.image_url %} + + {% else %} +
{{ type_info[0] }}
+ {% endif %} +
{{ pl.name }} + + {{ type_info[0] }} {{ type_info[1] }} + + {{ pl.description[:80] }}{% if pl.description | length > 80 %}…{% endif %} + +
+ +
+
+ {% else %} +

No hay elementos configurados todavía.

+ {% endif %} +
+ + {% include "admin/_system_buttons.html" %} +
+ + + + +{% endblock %} diff --git a/app/templates/admin/stats.html b/app/templates/admin/stats.html new file mode 100644 index 0000000..e2ba835 --- /dev/null +++ b/app/templates/admin/stats.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} +{% block title %}Estadísticas — Cantina{% endblock %} + +{% block content %} +
+ + +
+

Estadísticas de reproducción

+
+ + +
+ ← Día anterior + +
+ +
+ + {% if not is_today %} + Día siguiente → + {% else %} + Día siguiente → + {% endif %} + + {{ total_plays }} reproduccion{{ 'es' if total_plays != 1 else '' }} +
+ + {% if total_plays == 0 %} +
+

+ Sin reproducciones registradas para este día.
+ El historial se carga automáticamente desde Spotify cada 30 segundos. +

+
+ {% else %} + +
+ + +
+

🎵 Canciones más escuchadas

+
+ {% for item in top_tracks %} +
+ {{ loop.index }} +
+ {{ item.label }} +
+
+
+
+ {{ item.count }}x +
+ {% else %} +

Sin datos

+ {% endfor %} +
+
+ + +
+

🎤 Artistas más escuchados

+
+ {% for item in top_artists %} +
+ {{ loop.index }} +
+ {{ item.label }} +
+
+
+
+ {{ item.count }}x +
+ {% else %} +

Sin datos

+ {% endfor %} +
+
+ + +
+

🎸 Géneros más escuchados

+ {% if top_genres %} +
+ {% for item in top_genres %} +
+ {{ item.label }} +
+
+ {{ item.count }}x +
+
+
+ {% endfor %} +
+ {% else %} +

Sin información de géneros (los géneros se obtienen desde la API de artistas de Spotify).

+ {% endif %} +
+ +
+ {% endif %} + +
+ + + +{% endblock %} diff --git a/app/templates/admin/system_action.html b/app/templates/admin/system_action.html new file mode 100644 index 0000000..28523b4 --- /dev/null +++ b/app/templates/admin/system_action.html @@ -0,0 +1,53 @@ + + + + + + {{ action | capitalize }} — Cantina + + {% if action == "reiniciando" %} + + {% endif %} + + +
+
+ {% if action == "reiniciando" %}🔄{% else %}⏹{% endif %} +
+

{{ message }}

+ {% if action == "reiniciando" %} +

Redirigiendo en unos segundos…

+
+ {% else %} +

Puedes cerrar esta ventana.

+ {% endif %} +
+ + + + diff --git a/app/templates/admin/voting.html b/app/templates/admin/voting.html new file mode 100644 index 0000000..864c54a --- /dev/null +++ b/app/templates/admin/voting.html @@ -0,0 +1,166 @@ +{% extends "base.html" %} +{% block title %}Votación — Admin{% endblock %} + +{% block content %} +
+
+

Control de Votación

+
+ Playlists + ⏏ Cuenta Spotify + Cerrar sesión +
+
+ + {% if error %} +
{{ error }}
+ {% endif %} + {% if success %} +
{{ success }}
+ {% endif %} + + +
+

Configurar votación

+
+
+
+ + +
+
+
+ + +
+
+ + + + +
+ +
+ {% if config.is_active %} + 🟢 Activa · Ventana: {{ config.start_time }} – {{ config.end_time }} + {% else %} + 🔴 Inactiva + {% endif %} +
+
+ + +
+
+

Resultados ({{ total_votes }} votos)

+
+ {% if results and results[0].votes > 0 %} +
+ +
+ {% endif %} +
+ +
+
+
+ + {% if results %} +
+ {% for item in results %} + {% set pct = (item.votes / total_votes * 100) | round(1) if total_votes > 0 else 0 %} +
+
{{ loop.index }}
+ {% if item.playlist.image_url %} + + {% else %} +
🎵
+ {% endif %} +
+
+ {{ item.playlist.name }} + {% if loop.first and item.votes > 0 %} + 🏆 Ganador + {% endif %} +
+
+
+
+
+
+ {{ item.votes }} + {{ pct }}% +
+
+ {% endfor %} +
+ {% else %} +

No hay playlists configuradas.

+ {% endif %} +
+ +
+ + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..f3c5775 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {% block title %}Spotify Cantina{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..38f77ce --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,588 @@ +{% extends "base.html" %} +{% block title %}Reproductor — Cantina{% endblock %} + +{% block content %} +
+ + + + + +
+ Álbum +
+
+
+
+
Sin dispositivo activo
+
+
+ + +
+ 0:00 +
+
+
+ 0:00 +
+ + +
+ + + +
+ + +
+ 🔈 + + 🔊 +
+ + +
+ +
+ + +
+
+ + +
+
+ +
+
+

Cargando...

+
+
+ +
+ + + + + + + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..295b61b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + cantina: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./cantina.db:/app/cantina.db + - ./.spotify_cache:/app/.spotify_cache + restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..3680266 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +# Archivos de datos — crearlos si no existen para que los bind mounts funcionen +touch cantina.db .spotify_cache + +# Certificado SSL autofirmado — solo se genera una vez +if [ ! -f cert.pem ] || [ ! -f key.pem ]; then + echo "Generando certificado SSL autofirmado..." + openssl req -x509 -newkey rsa:2048 \ + -keyout key.pem -out cert.pem \ + -days 365 -nodes \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ + 2>/dev/null +fi + +exec uvicorn app.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --ssl-keyfile key.pem \ + --ssl-certfile cert.pem diff --git a/gen_cert.py b/gen_cert.py new file mode 100644 index 0000000..657bed5 --- /dev/null +++ b/gen_cert.py @@ -0,0 +1,42 @@ +"""Genera un certificado SSL autofirmado para localhost.""" +import datetime +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + +subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "localhost"), +]) + +cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName("localhost"), + x509.IPAddress(__import__("ipaddress").IPv4Address("127.0.0.1")), + ]), + critical=False, + ) + .sign(key, hashes.SHA256()) +) + +with open("cert.pem", "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + +with open("key.pem", "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + +print("Certificados generados: cert.pem y key.pem") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..366d59c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +spotipy==2.24.0 +sqlalchemy==2.0.36 +jinja2==3.1.4 +python-dotenv==1.0.1 +python-multipart==0.0.12 +itsdangerous==2.2.0 +pydantic-settings==2.5.2 diff --git a/static/placeholder.svg b/static/placeholder.svg new file mode 100644 index 0000000..a617f46 --- /dev/null +++ b/static/placeholder.svg @@ -0,0 +1,4 @@ + + + 🎵 + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..87581ee --- /dev/null +++ b/static/style.css @@ -0,0 +1,340 @@ +/* ── Reset & Base ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #121212; + --surface: #1e1e1e; + --surface2: #2a2a2a; + --green: #1db954; + --green-dark: #17a349; + --text: #e0e0e0; + --text-muted: #888; + --red: #e74c3c; + --radius: 10px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +a { color: var(--green); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Navbar ── */ +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + background: #000; + padding: 0.75rem 1.5rem; + position: sticky; + top: 0; + z-index: 100; + border-bottom: 1px solid #333; +} +.brand { font-size: 1.1rem; font-weight: 700; color: var(--green); letter-spacing: 1px; } +.nav-links { display: flex; gap: 1.5rem; } +.nav-links a { color: var(--text-muted); font-size: 0.9rem; } +.nav-links a:hover { color: var(--text); } +/* ── Player ── */ +.player-container { + max-width: 640px; + margin: 2rem auto; + padding: 0 1rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.now-playing { + display: flex; + gap: 1.25rem; + align-items: center; + background: var(--surface); + padding: 1.25rem; + border-radius: var(--radius); +} + +.album-art { + width: 90px; + height: 90px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + background: var(--surface2); +} + +.track-info { flex: 1; overflow: hidden; } +.track-name { font-size: 1.05rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.artists { color: var(--text-muted); font-size: 0.875rem; margin-top: 3px; } +.album-label { color: var(--text-muted); font-size: 0.8rem; margin-top: 2px; } +.device-label { color: var(--green); font-size: 0.78rem; margin-top: 6px; } + +/* Progress */ +.progress-bar-container { + display: flex; + align-items: center; + gap: 0.6rem; +} +.progress-bar-bg { + flex: 1; + height: 4px; + background: var(--surface2); + border-radius: 2px; + overflow: hidden; +} +.progress-bar-fill { + height: 100%; + background: var(--green); + border-radius: 2px; + transition: width 0.5s linear; +} +.time-label { font-size: 0.75rem; color: var(--text-muted); min-width: 32px; } + +/* Controls */ +.controls { + display: flex; + align-items: center; + justify-content: center; + gap: 1.25rem; +} + +.btn-control { + background: none; + border: none; + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; + padding: 0.5rem; + border-radius: 50%; + transition: color 0.15s; +} +.btn-control:hover { color: var(--text); } + +.btn-play { + background: var(--green); + border: none; + color: #000; + font-size: 1.2rem; + width: 52px; + height: 52px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} +.btn-play:hover { background: var(--green-dark); transform: scale(1.05); } +.btn-play:active { transform: scale(0.97); } + +/* Volume */ +.volume-row { + display: flex; + align-items: center; + gap: 0.6rem; +} +.volume-icon { font-size: 1rem; } +.volume-slider { + flex: 1; + accent-color: var(--green); + cursor: pointer; + height: 4px; +} + +/* Device */ +.device-section, .playlist-section { display: flex; flex-direction: column; gap: 0.5rem; } +.section-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.device-row { display: flex; gap: 0.5rem; } + +.select { + flex: 1; + background: var(--surface2); + color: var(--text); + border: 1px solid #444; + border-radius: 6px; + padding: 0.45rem 0.6rem; + font-size: 0.875rem; +} + +/* Playlists grid */ +.playlist-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.75rem; +} + +.playlist-card { + background: var(--surface); + border-radius: var(--radius); + padding: 0.6rem; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + text-align: center; +} +.playlist-card.clickable:hover { background: var(--surface2); transform: scale(1.02); cursor: pointer; } +.playlist-card.clickable:active { transform: scale(0.98); } + +.playlist-thumb { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 6px; +} +.playlist-thumb-placeholder { + width: 100%; + aspect-ratio: 1; + background: var(--surface2); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; +} +.playlist-thumb-emoji { font-size: 2.4rem; } + +.playlist-card-name { + font-size: 0.78rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +/* ── Admin ── */ +.admin-container { + max-width: 900px; + margin: 2rem auto; + padding: 0 1rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} +.admin-header { display: flex; align-items: center; justify-content: space-between; } +.admin-header h1 { font-size: 1.4rem; } + +.card { + background: var(--surface); + border-radius: var(--radius); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} +.card h2 { font-size: 1rem; font-weight: 600; } + +.add-form { display: flex; flex-direction: column; gap: 0.5rem; } +.form-row { display: flex; gap: 0.6rem; } +.hint { color: var(--text-muted); font-size: 0.78rem; } + +.input { + background: var(--surface2); + color: var(--text); + border: 1px solid #444; + border-radius: 6px; + padding: 0.55rem 0.75rem; + font-size: 0.9rem; + flex: 1; + outline: none; +} +.input:focus { border-color: var(--green); } + +/* Table */ +.table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.table th { + text-align: left; + color: var(--text-muted); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid #333; +} +.table td { padding: 0.6rem; border-bottom: 1px solid #2a2a2a; vertical-align: middle; } +.table tr:last-child td { border-bottom: none; } + +.table-thumb { + width: 44px; + height: 44px; + border-radius: 4px; + object-fit: cover; +} +.table-thumb-placeholder { + width: 44px; + height: 44px; + background: var(--surface2); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; +} +.td-name { font-weight: 500; max-width: 180px; } +.td-desc { color: var(--text-muted); font-size: 0.82rem; max-width: 220px; } +.code-id { font-family: monospace; font-size: 0.8rem; color: var(--text-muted); background: var(--surface2); padding: 2px 5px; border-radius: 3px; } + +/* Buttons */ +.btn-primary { + background: var(--green); + color: #000; + font-weight: 600; + border: none; + border-radius: 6px; + padding: 0.55rem 1.1rem; + cursor: pointer; + font-size: 0.9rem; + white-space: nowrap; + transition: background 0.15s; +} +.btn-primary:hover { background: var(--green-dark); } + +.btn-sm { + background: var(--surface2); + color: var(--text); + border: 1px solid #444; + border-radius: 6px; + padding: 0.4rem 0.7rem; + cursor: pointer; + font-size: 0.82rem; + transition: background 0.15s; +} +.btn-sm:hover { background: #333; } +.btn-danger { background: transparent; color: var(--red); border-color: var(--red); } +.btn-danger:hover { background: var(--red); color: #fff; } + +/* Alerts */ +.alert { + padding: 0.75rem 1rem; + border-radius: 6px; + font-size: 0.875rem; +} +.alert-error { background: rgba(231,76,60,0.15); color: #e74c3c; border: 1px solid rgba(231,76,60,0.3); } +.alert-success { background: rgba(29,185,84,0.15); color: var(--green); border: 1px solid rgba(29,185,84,0.3); } + +/* Login */ +.login-container { + max-width: 360px; + margin: 5rem auto; + padding: 2rem; + background: var(--surface); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 1rem; +} +.login-container h1 { text-align: center; } +.login-form { display: flex; flex-direction: column; gap: 0.6rem; } +.login-form label { font-size: 0.85rem; color: var(--text-muted); } +.back-link { text-align: center; font-size: 0.85rem; } + +/* Misc */ +.empty-msg { color: var(--text-muted); font-size: 0.9rem; }