commit inicial

This commit is contained in:
2026-04-23 00:39:58 -04:00
commit a1b9c0139d
32 changed files with 2836 additions and 0 deletions
View File
+365
View File
@@ -0,0 +1,365 @@
import os
import re
import signal
import sys
import threading
import time
from datetime import datetime
from fastapi import APIRouter, Request, Depends, Form, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Playlist, Vote, VotingConfig
from app.config import settings
from app import spotify
router = APIRouter(prefix="/admin", tags=["admin"])
templates = Jinja2Templates(directory="app/templates")
_VALID_TYPES = {"playlist", "album", "artist", "track"}
_URI_RE = re.compile(r"spotify:(playlist|album|artist|track):([A-Za-z0-9]+)")
_URL_RE = re.compile(r"open\.spotify\.com/(playlist|album|artist|track)/([A-Za-z0-9]+)")
_BARE_ID_RE = re.compile(r"^[A-Za-z0-9]{22}$")
_TYPE_LABELS = {
"playlist": ("📋", "Playlist"),
"album": ("💿", "Álbum"),
"artist": ("🎤", "Artista"),
"track": ("🎵", "Canción"),
}
def _extract_spotify_item(value: str) -> tuple[str, str] | None:
"""Retorna (spotify_id, spotify_type) o None si la URL no es válida."""
value = value.strip().split("?")[0].rstrip("/")
if m := _URI_RE.search(value):
return m.group(2), m.group(1)
if m := _URL_RE.search(value):
return m.group(2), m.group(1)
if _BARE_ID_RE.match(value):
return value, "playlist"
return None
def _fetch_metadata(sp, spotify_id: str, spotify_type: str) -> dict:
if spotify_type == "playlist":
data = sp.playlist(spotify_id, fields="name,description,images")
return {
"name": data["name"],
"description": data.get("description") or "",
"image_url": data["images"][0]["url"] if data.get("images") else "",
}
if spotify_type == "album":
data = sp.album(spotify_id)
artists = ", ".join(a["name"] for a in data.get("artists", []))
return {
"name": f"{data['name']}",
"description": f"{artists} · {str(data.get('release_date', ''))[:4]}",
"image_url": data["images"][0]["url"] if data.get("images") else "",
}
if spotify_type == "artist":
data = sp.artist(spotify_id)
return {
"name": data["name"],
"description": "Artista",
"image_url": data["images"][0]["url"] if data.get("images") else "",
}
if spotify_type == "track":
data = sp.track(spotify_id)
artists = ", ".join(a["name"] for a in data.get("artists", []))
return {
"name": data["name"],
"description": f"{artists} · {data['album']['name']}",
"image_url": data["album"]["images"][0]["url"] if data["album"].get("images") else "",
}
return {"name": spotify_id, "description": "", "image_url": ""}
def _require_admin(request: Request):
if not request.session.get("admin_logged_in"):
raise HTTPException(status_code=302, headers={"Location": "/admin/login"})
# ── Login ──────────────────────────────────────────────────────────────────────
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("admin/login.html", {"request": request, "error": None})
@router.post("/login")
def login(request: Request, username: str = Form(...), password: str = Form(...)):
if username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD:
request.session["admin_logged_in"] = True
return RedirectResponse(url="/admin/playlists", status_code=303)
return templates.TemplateResponse(
"admin/login.html", {"request": request, "error": "Usuario o contraseña incorrectos"}, status_code=401
)
@router.get("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/admin/login")
# ── Playlists ──────────────────────────────────────────────────────────────────
@router.get("/playlists", response_class=HTMLResponse)
def list_playlists(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": None, "success": None},
)
@router.post("/playlists")
def add_playlist(
request: Request,
spotify_url: str = Form(...),
db: Session = Depends(get_db),
):
_require_admin(request)
parsed = _extract_spotify_item(spotify_url)
if not parsed:
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": "URL no reconocida. Acepta links de Spotify de canciones, álbumes, artistas o playlists.", "success": None},
status_code=400,
)
spotify_id, spotify_type = parsed
existing = db.query(Playlist).filter(Playlist.spotify_id == spotify_id).first()
if existing:
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": "Este elemento ya está en el mantenedor", "success": None},
status_code=400,
)
try:
sp = spotify.get_client()
meta = _fetch_metadata(sp, spotify_id, spotify_type)
except Exception:
meta = {"name": spotify_id, "description": "", "image_url": ""}
item = Playlist(
name=meta["name"],
spotify_id=spotify_id,
spotify_type=spotify_type,
description=meta["description"],
image_url=meta["image_url"],
)
db.add(item)
db.commit()
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
label = _TYPE_LABELS.get(spotify_type, ("", spotify_type))[1]
return templates.TemplateResponse(
"admin/playlists.html",
{"request": request, "playlists": playlists, "type_labels": _TYPE_LABELS,
"error": None, "success": f'{label} "{meta["name"]}" agregado correctamente'},
)
@router.post("/playlists/{playlist_id}/edit")
def edit_playlist(
playlist_id: int,
request: Request,
name: str = Form(...),
description: str = Form(""),
emoji: str = Form(""),
db: Session = Depends(get_db),
):
_require_admin(request)
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Elemento no encontrado")
playlist.name = name.strip() or playlist.name
playlist.description = description.strip()
playlist.emoji = emoji.strip()
db.commit()
return RedirectResponse(url="/admin/playlists", status_code=303)
@router.post("/playlists/{playlist_id}/delete")
def delete_playlist(playlist_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
playlist = db.query(Playlist).filter(Playlist.id == playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist no encontrada")
db.delete(playlist)
db.commit()
return RedirectResponse(url="/admin/playlists", status_code=303)
# ── Votación ────────────────────────────────────────────────────────────────────
def _get_or_create_config(db: Session) -> VotingConfig:
config = db.query(VotingConfig).first()
if not config:
config = VotingConfig(start_time="12:00", end_time="13:00", is_active=False)
db.add(config)
db.commit()
db.refresh(config)
return config
def _vote_results(db: Session) -> list[dict]:
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
results = []
for pl in playlists:
count = db.query(Vote).filter(Vote.playlist_id == pl.id).count()
results.append({"playlist": pl, "votes": count})
results.sort(key=lambda x: x["votes"], reverse=True)
return results
@router.get("/voting", response_class=HTMLResponse)
def voting_admin(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
config = _get_or_create_config(db)
results = _vote_results(db)
total_votes = sum(r["votes"] for r in results)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": total_votes,
"error": None,
"success": None,
},
)
@router.post("/voting")
def update_voting_config(
request: Request,
start_time: str = Form(...),
end_time: str = Form(...),
is_active: str = Form(default="off"),
db: Session = Depends(get_db),
):
_require_admin(request)
config = _get_or_create_config(db)
try:
datetime.strptime(start_time, "%H:%M")
datetime.strptime(end_time, "%H:%M")
except ValueError:
results = _vote_results(db)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"error": "Formato de hora inválido. Use HH:MM",
"success": None,
},
status_code=400,
)
if start_time >= end_time:
results = _vote_results(db)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"error": "La hora de inicio debe ser anterior a la hora de fin",
"success": None,
},
status_code=400,
)
config.start_time = start_time
config.end_time = end_time
config.is_active = is_active == "on"
db.commit()
results = _vote_results(db)
return templates.TemplateResponse(
"admin/voting.html",
{
"request": request,
"config": config,
"results": results,
"total_votes": sum(r["votes"] for r in results),
"error": None,
"success": "Configuración guardada",
},
)
@router.post("/voting/reset")
def reset_votes(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
db.query(Vote).delete()
db.commit()
return RedirectResponse(url="/admin/voting", status_code=303)
@router.post("/voting/play-winner")
def play_winner(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
results = _vote_results(db)
if not results or results[0]["votes"] == 0:
raise HTTPException(status_code=400, detail="No hay votos registrados")
winner = results[0]["playlist"]
try:
sp = spotify.get_client()
sp.start_playback(context_uri=f"spotify:playlist:{winner.spotify_id}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return RedirectResponse(url="/admin/voting", status_code=303)
# ── Sistema ────────────────────────────────────────────────────────────────────
@router.post("/system/restart")
def system_restart(request: Request):
_require_admin(request)
def _do():
time.sleep(0.8)
os.execv(sys.executable, [sys.executable] + sys.argv)
threading.Thread(target=_do, daemon=True).start()
return templates.TemplateResponse(
"admin/system_action.html",
{"request": request, "action": "reiniciando", "message": "El servidor se está reiniciando…"},
)
@router.post("/system/shutdown")
def system_shutdown(request: Request):
_require_admin(request)
def _do():
time.sleep(0.8)
os.kill(os.getpid(), signal.SIGTERM)
threading.Thread(target=_do, daemon=True).start()
return templates.TemplateResponse(
"admin/system_action.html",
{"request": request, "action": "apagando", "message": "El servidor se está apagando…"},
)
+32
View File
@@ -0,0 +1,32 @@
import os
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from app import spotify
from app.config import settings
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/login")
def spotify_login():
return RedirectResponse(url=spotify.get_auth_url())
@router.get("/callback")
def spotify_callback(code: str):
spotify.exchange_code(code)
return RedirectResponse(url="/")
@router.get("/logout")
def spotify_logout():
try:
os.remove(".spotify_cache")
except FileNotFoundError:
pass
return RedirectResponse(url="/auth/login")
@router.get("/status")
def auth_status():
return {"authenticated": spotify.is_authenticated()}
+160
View File
@@ -0,0 +1,160 @@
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app import spotify
router = APIRouter(prefix="/player", tags=["player"])
class VolumeBody(BaseModel):
volume: int
class DeviceBody(BaseModel):
device_id: str
class PlayBody(BaseModel):
context_uri: str | None = None
uris: list[str] | None = None
device_id: str | None = None
def _sp():
return spotify.get_client()
@router.get("/current")
def current_track():
sp = _sp()
track = sp.current_playback()
if not track or not track.get("item"):
return {"playing": False}
item = track["item"]
artists = ", ".join(a["name"] for a in item["artists"])
album = item["album"]
image = album["images"][0]["url"] if album["images"] else ""
return {
"playing": track["is_playing"],
"track": item["name"],
"artists": artists,
"album": album["name"],
"image": image,
"progress_ms": track["progress_ms"],
"duration_ms": item["duration_ms"],
"volume": track["device"]["volume_percent"] if track.get("device") else None,
"device": track["device"]["name"] if track.get("device") else None,
"context_uri": track["context"]["uri"] if track.get("context") else None,
}
@router.get("/devices")
def get_devices():
sp = _sp()
result = sp.devices()
return result.get("devices", [])
@router.post("/play")
def play(body: PlayBody):
sp = _sp()
kwargs = {}
if body.uris:
kwargs["uris"] = body.uris
elif body.context_uri:
kwargs["context_uri"] = body.context_uri
if body.device_id:
kwargs["device_id"] = body.device_id
sp.start_playback(**kwargs)
return {"ok": True}
@router.post("/pause")
def pause():
sp = _sp()
sp.pause_playback()
return {"ok": True}
@router.post("/next")
def next_track():
sp = _sp()
sp.next_track()
return {"ok": True}
@router.post("/previous")
def previous_track():
sp = _sp()
sp.previous_track()
return {"ok": True}
@router.post("/volume")
def set_volume(body: VolumeBody):
if not 0 <= body.volume <= 100:
raise HTTPException(status_code=400, detail="El volumen debe estar entre 0 y 100")
sp = _sp()
sp.volume(body.volume)
return {"ok": True}
@router.post("/device")
def set_device(body: DeviceBody):
sp = _sp()
sp.transfer_playback(device_id=body.device_id, force_play=False)
return {"ok": True}
@router.get("/tracks")
def get_tracks(id: str = Query(...), type: str = Query(...)):
sp = _sp()
tracks = []
if type == "playlist":
results = sp.playlist_tracks(
id, limit=50,
fields="items(track(id,name,artists,duration_ms))",
)
for item in (results.get("items") or []):
t = item.get("track")
if t and t.get("id"):
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
elif type == "album":
results = sp.album_tracks(id, limit=50)
for t in (results.get("items") or []):
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
elif type == "artist":
results = sp.artist_top_tracks(id)
for t in (results.get("tracks") or []):
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
elif type == "track":
t = sp.track(id)
tracks.append({
"id": t["id"],
"name": t["name"],
"artists": ", ".join(a["name"] for a in t["artists"]),
"duration_ms": t["duration_ms"],
})
return tracks
+93
View File
@@ -0,0 +1,93 @@
from collections import Counter
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import PlayHistory
router = APIRouter(prefix="/stats", tags=["stats"])
templates = Jinja2Templates(directory="app/templates")
_TOP_N = 10
def _stats_for_day(db: Session, selected: date) -> dict:
records = (
db.query(PlayHistory)
.filter(func.date(PlayHistory.played_at) == selected.isoformat())
.order_by(PlayHistory.played_at.asc())
.all()
)
track_counts: Counter = Counter()
artist_counts: Counter = Counter()
genre_counts: Counter = Counter()
for r in records:
track_counts[(r.track_id, r.track_name)] += 1
for artist in (a.strip() for a in r.artists.split(",") if a.strip()):
artist_counts[artist] += 1
for genre in (g.strip() for g in r.genres.split(",") if g.strip()):
genre_counts[genre] += 1
def _ranked(counter: Counter) -> list[dict]:
top = counter.most_common(_TOP_N)
max_val = top[0][1] if top else 1
return [
{"label": k if isinstance(k, str) else k[1], "count": v, "pct": round(v / max_val * 100)}
for k, v in top
]
return {
"total_plays": len(records),
"top_tracks": _ranked(track_counts),
"top_artists": _ranked(artist_counts),
"top_genres": _ranked(genre_counts),
}
@router.get("/", response_class=HTMLResponse)
def stats_page(request: Request, day: str | None = None, db: Session = Depends(get_db)):
try:
selected_date = date.fromisoformat(day) if day else date.today()
except ValueError:
selected_date = date.today()
data = _stats_for_day(db, selected_date)
# Días con al menos una reproducción (para el selector)
days_with_data = [
str(r[0])
for r in db.query(func.date(PlayHistory.played_at))
.distinct()
.order_by(func.date(PlayHistory.played_at).desc())
.limit(30)
.all()
]
return templates.TemplateResponse(
"admin/stats.html",
{
"request": request,
"selected_date": selected_date.isoformat(),
"prev_date": (selected_date - timedelta(days=1)).isoformat(),
"next_date": (selected_date + timedelta(days=1)).isoformat(),
"is_today": selected_date == date.today(),
"days_with_data": days_with_data,
**data,
},
)
@router.get("/api")
def stats_api(day: str | None = None, db: Session = Depends(get_db)):
try:
selected_date = date.fromisoformat(day) if day else date.today()
except ValueError:
selected_date = date.today()
return {"date": selected_date.isoformat(), **_stats_for_day(db, selected_date)}
+117
View File
@@ -0,0 +1,117 @@
import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Playlist, Vote, VotingConfig
router = APIRouter(prefix="/voting", tags=["voting"])
COOLDOWN_SECONDS = 10
def _voter_token(request: Request) -> str:
token = request.session.get("voter_token")
if not token:
token = str(uuid.uuid4())
request.session["voter_token"] = token
return token
def _is_open(config: VotingConfig | None) -> bool:
if not config or not config.is_active:
return False
now = datetime.now().strftime("%H:%M")
return config.start_time <= now <= config.end_time
def _cooldown_remaining(db: Session, voter_token: str) -> int:
"""Segundos restantes de cooldown. 0 si puede votar."""
last = (
db.query(Vote)
.filter(Vote.voter_token == voter_token)
.order_by(Vote.voted_at.desc())
.first()
)
if not last:
return 0
voted_at = last.voted_at
if voted_at.tzinfo is None:
voted_at = voted_at.replace(tzinfo=timezone.utc)
elapsed = (datetime.now(timezone.utc) - voted_at).total_seconds()
return max(0, int(COOLDOWN_SECONDS - elapsed))
@router.get("/status")
def voting_status(request: Request, db: Session = Depends(get_db)):
config = db.query(VotingConfig).first()
voter_token = _voter_token(request)
is_open = _is_open(config)
playlists = db.query(Playlist).order_by(Playlist.created_at.desc()).all()
vote_counts: dict[int, int] = {
pl.id: db.query(Vote).filter(Vote.playlist_id == pl.id).count()
for pl in playlists
}
last_vote = (
db.query(Vote)
.filter(Vote.voter_token == voter_token)
.order_by(Vote.voted_at.desc())
.first()
)
return {
"is_open": is_open,
"cooldown_seconds": COOLDOWN_SECONDS,
"cooldown_remaining": _cooldown_remaining(db, voter_token),
"config": {
"start_time": config.start_time if config else None,
"end_time": config.end_time if config else None,
"is_active": config.is_active if config else False,
},
"my_last_vote_playlist_id": last_vote.playlist_id if last_vote else None,
"playlists": [
{
"id": pl.id,
"spotify_id": pl.spotify_id,
"spotify_type": pl.spotify_type,
"name": pl.name,
"image_url": pl.image_url,
"emoji": pl.emoji or "",
"description": pl.description or "",
"votes": vote_counts.get(pl.id, 0),
}
for pl in playlists
],
}
@router.post("/vote/{playlist_id}")
def cast_vote(playlist_id: int, request: Request, db: Session = Depends(get_db)):
config = db.query(VotingConfig).first()
if not _is_open(config):
raise HTTPException(status_code=403, detail="La votación no está abierta en este momento")
pl = db.query(Playlist).filter(Playlist.id == playlist_id).first()
if not pl:
raise HTTPException(status_code=404, detail="Playlist no encontrada")
voter_token = _voter_token(request)
remaining = _cooldown_remaining(db, voter_token)
if remaining > 0:
raise HTTPException(
status_code=429,
detail={"message": "Debes esperar antes de votar de nuevo", "remaining": remaining},
)
db.add(Vote(
playlist_id=playlist_id,
voter_token=voter_token,
voted_at=datetime.now(timezone.utc),
))
db.commit()
return {"ok": True, "cooldown_seconds": COOLDOWN_SECONDS}