-
-
MELODIX
-
Código de Restablecimiento
-
-
-
Hola,
-
Has solicitado restablecer la contraseña de tu cuenta de Melodix. Utiliza el siguiente código de verificación de 6 dígitos:
-
-
-
- {code}
-
-
-
-
Este código es válido por 15 minutos. Si no solicitaste este cambio, puedes ignorar este correo de forma segura.
-
-
© {datetime_year()} Melodix App. Todos los derechos reservados.
-
-
-
- """
-
- # 1. ENVIAR POR BREVO API (SI BREVO_API_KEY ESTÁ CONFIGURADA)
- if brevo_api_key:
- brevo_api_key = brevo_api_key.strip("'\" ")
- try:
- import requests
- url = "https://api.brevo.com/v3/smtp/email"
- headers = {
- "api-key": brevo_api_key,
- "Content-Type": "application/json"
- }
-
- # El remitente debe ser el verificado en Brevo (típicamente smtp_user)
- sender_email = smtp_user or "mifemmnic@gmail.com"
- sender_name = "Melodix"
- if smtp_from:
- if "<" in smtp_from and ">" in smtp_from:
- parts = smtp_from.split("<")
- sender_name = parts[0].strip()
- sender_email = parts[1].replace(">", "").strip()
- elif "@" in smtp_from:
- sender_email = smtp_from.strip()
-
- payload = {
- "sender": {
- "name": sender_name,
- "email": sender_email
- },
- "to": [
- {
- "email": to_email
- }
- ],
- "subject": "Recuperación de Contraseña - Melodix",
- "htmlContent": html_content
- }
-
- res = requests.post(url, json=payload, headers=headers, timeout=15)
- if res.status_code in (200, 201, 202):
- logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Brevo API")
- return True
- else:
- err_msg = f"Brevo API retornó error {res.status_code}: {res.text}"
- logger.error(err_msg)
- if raise_on_error:
- raise Exception(err_msg)
- return False
- except Exception as e:
- logger.error(f"Error enviando correo vía Brevo a {to_email}: {e}", exc_info=True)
- if raise_on_error:
- raise e
- return False
-
- # 2. ENVIAR POR RESEND API (SI RESEND_API_KEY ESTÁ CONFIGURADA)
- if resend_api_key:
- resend_api_key = resend_api_key.strip("'\" ")
- try:
- import requests
- url = "https://api.resend.com/emails"
- headers = {
- "Authorization": f"Bearer {resend_api_key}",
- "Content-Type": "application/json"
- }
-
- from_addr = "onboarding@resend.dev"
- if smtp_from and "@" in smtp_from:
- domain = smtp_from.split("@")[-1].replace(">", "").strip()
- if domain not in ["gmail.com", "outlook.com", "hotmail.com", "yahoo.com"]:
- from_addr = smtp_from
-
- if not from_addr.startswith("Melodix"):
- from_addr = f"Melodix <{from_addr}>"
-
- payload = {
- "from": from_addr,
- "to": [to_email],
- "subject": "Recuperación de Contraseña - Melodix",
- "html": html_content
- }
-
- res = requests.post(url, json=payload, headers=headers, timeout=15)
- if res.status_code in (200, 201):
- logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Resend API")
- return True
- else:
- err_msg = f"Resend API retornó error {res.status_code}: {res.text}"
- logger.error(err_msg)
- if raise_on_error:
- raise Exception(err_msg)
- return False
- except Exception as e:
- logger.error(f"Error enviando correo vía Resend a {to_email}: {e}", exc_info=True)
- if raise_on_error:
- raise e
- return False
-
- # 2. ENVIAR POR SMTP TRADICIONAL (FALLBACK)
- smtp_host = os.getenv("SMTP_HOST")
- smtp_port = os.getenv("SMTP_PORT")
- smtp_user = os.getenv("SMTP_USER")
- smtp_password = os.getenv("SMTP_PASSWORD")
-
- # Limpiar comillas iniciales/finales y espacios (común al pegar variables en Render)
- if smtp_host: smtp_host = smtp_host.strip("'\" ")
- if smtp_port: smtp_port = smtp_port.strip("'\" ")
- if smtp_user: smtp_user = smtp_user.strip("'\" ")
- if smtp_password: smtp_password = smtp_password.strip("'\" ")
-
- if not smtp_from:
- smtp_from = f"Melodix <{smtp_user}>"
-
- if not smtp_host or not smtp_port or not smtp_user or not smtp_password:
- logger.warning(f"Configuración SMTP incompleta. host={smtp_host}, port={smtp_port}, user={smtp_user}. No se puede enviar correo.")
- logger.info(f"[SIMULADO] Código de recuperación para {to_email}: {code}")
- return False
-
- try:
- # Configurar el mensaje
- msg = MIMEMultipart("alternative")
- msg["Subject"] = "Recuperación de Contraseña - Melodix"
- msg["From"] = smtp_from
- msg["To"] = to_email
- msg.attach(MIMEText(html_content, "html"))
-
- # Conectar al servidor SMTP
- port = int(smtp_port)
- if port == 465:
- server = smtplib.SMTP_SSL(smtp_host, port, timeout=15)
- else:
- server = smtplib.SMTP(smtp_host, port, timeout=15)
- server.ehlo()
- server.starttls()
- server.ehlo()
-
- server.login(smtp_user, smtp_password)
- server.sendmail(smtp_user, [to_email], msg.as_string())
- server.quit()
-
- logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía SMTP")
- return True
- except Exception as e:
- logger.error(f"Error enviando correo SMTP a {to_email}: {e}", exc_info=True)
- if raise_on_error:
- raise e
- return False
-
-def datetime_year():
- from datetime import datetime
- return datetime.utcnow().year
+import os
+import smtplib
+import logging
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+
+logger = logging.getLogger(__name__)
+
+def send_recovery_email(to_email: str, code: str, raise_on_error: bool = False):
+ """
+ Envía un correo electrónico con el código de recuperación de contraseña de 6 dígitos.
+ Soporta Brevo API (ideal para enviar a cualquier remitente gratis sin dominio),
+ Resend API (requiere dominio o enviar solo al creador), o SMTP tradicional como fallback.
+ """
+ brevo_api_key = os.getenv("BREVO_API_KEY")
+ resend_api_key = os.getenv("RESEND_API_KEY")
+ smtp_from = os.getenv("SMTP_FROM")
+ smtp_user = os.getenv("SMTP_USER")
+
+ if smtp_from: smtp_from = smtp_from.strip("'\" ")
+ if smtp_user: smtp_user = smtp_user.strip("'\" ")
+
+ # HTML estilizado y profesional
+ html_content = f"""
+
+
+
+
+
MELODIX
+
Código de Restablecimiento
+
+
+
Hola,
+
Has solicitado restablecer la contraseña de tu cuenta de Melodix. Utiliza el siguiente código de verificación de 6 dígitos:
+
+
+
+ {code}
+
+
+
+
Este código es válido por 15 minutos. Si no solicitaste este cambio, puedes ignorar este correo de forma segura.
+
+
© {datetime_year()} Melodix App. Todos los derechos reservados.
+
+
+
+ """
+
+ # 1. ENVIAR POR BREVO API (SI BREVO_API_KEY ESTÁ CONFIGURADA)
+ if brevo_api_key:
+ brevo_api_key = brevo_api_key.strip("'\" ")
+ try:
+ import requests
+ url = "https://api.brevo.com/v3/smtp/email"
+ headers = {
+ "api-key": brevo_api_key,
+ "Content-Type": "application/json"
+ }
+
+ # El remitente debe ser el verificado en Brevo (típicamente smtp_user)
+ sender_email = smtp_user or "mifemmnic@gmail.com"
+ sender_name = "Melodix"
+ if smtp_from:
+ if "<" in smtp_from and ">" in smtp_from:
+ parts = smtp_from.split("<")
+ sender_name = parts[0].strip()
+ sender_email = parts[1].replace(">", "").strip()
+ elif "@" in smtp_from:
+ sender_email = smtp_from.strip()
+
+ payload = {
+ "sender": {
+ "name": sender_name,
+ "email": sender_email
+ },
+ "to": [
+ {
+ "email": to_email
+ }
+ ],
+ "subject": "Recuperación de Contraseña - Melodix",
+ "htmlContent": html_content
+ }
+
+ res = requests.post(url, json=payload, headers=headers, timeout=15)
+ if res.status_code in (200, 201, 202):
+ logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Brevo API")
+ return True
+ else:
+ err_msg = f"Brevo API retornó error {res.status_code}: {res.text}"
+ logger.error(err_msg)
+ if raise_on_error:
+ raise Exception(err_msg)
+ return False
+ except Exception as e:
+ logger.error(f"Error enviando correo vía Brevo a {to_email}: {e}", exc_info=True)
+ if raise_on_error:
+ raise e
+ return False
+
+ # 2. ENVIAR POR RESEND API (SI RESEND_API_KEY ESTÁ CONFIGURADA)
+ if resend_api_key:
+ resend_api_key = resend_api_key.strip("'\" ")
+ try:
+ import requests
+ url = "https://api.resend.com/emails"
+ headers = {
+ "Authorization": f"Bearer {resend_api_key}",
+ "Content-Type": "application/json"
+ }
+
+ from_addr = "onboarding@resend.dev"
+ if smtp_from and "@" in smtp_from:
+ domain = smtp_from.split("@")[-1].replace(">", "").strip()
+ if domain not in ["gmail.com", "outlook.com", "hotmail.com", "yahoo.com"]:
+ from_addr = smtp_from
+
+ if not from_addr.startswith("Melodix"):
+ from_addr = f"Melodix <{from_addr}>"
+
+ payload = {
+ "from": from_addr,
+ "to": [to_email],
+ "subject": "Recuperación de Contraseña - Melodix",
+ "html": html_content
+ }
+
+ res = requests.post(url, json=payload, headers=headers, timeout=15)
+ if res.status_code in (200, 201):
+ logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía Resend API")
+ return True
+ else:
+ err_msg = f"Resend API retornó error {res.status_code}: {res.text}"
+ logger.error(err_msg)
+ if raise_on_error:
+ raise Exception(err_msg)
+ return False
+ except Exception as e:
+ logger.error(f"Error enviando correo vía Resend a {to_email}: {e}", exc_info=True)
+ if raise_on_error:
+ raise e
+ return False
+
+ # 2. ENVIAR POR SMTP TRADICIONAL (FALLBACK)
+ smtp_host = os.getenv("SMTP_HOST")
+ smtp_port = os.getenv("SMTP_PORT")
+ smtp_user = os.getenv("SMTP_USER")
+ smtp_password = os.getenv("SMTP_PASSWORD")
+
+ # Limpiar comillas iniciales/finales y espacios (común al pegar variables en Render)
+ if smtp_host: smtp_host = smtp_host.strip("'\" ")
+ if smtp_port: smtp_port = smtp_port.strip("'\" ")
+ if smtp_user: smtp_user = smtp_user.strip("'\" ")
+ if smtp_password: smtp_password = smtp_password.strip("'\" ")
+
+ if not smtp_from:
+ smtp_from = f"Melodix <{smtp_user}>"
+
+ if not smtp_host or not smtp_port or not smtp_user or not smtp_password:
+ logger.warning(f"Configuración SMTP incompleta. host={smtp_host}, port={smtp_port}, user={smtp_user}. No se puede enviar correo.")
+ logger.info(f"[SIMULADO] Código de recuperación para {to_email}: {code}")
+ return False
+
+ try:
+ # Configurar el mensaje
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = "Recuperación de Contraseña - Melodix"
+ msg["From"] = smtp_from
+ msg["To"] = to_email
+ msg.attach(MIMEText(html_content, "html"))
+
+ # Conectar al servidor SMTP
+ port = int(smtp_port)
+ if port == 465:
+ server = smtplib.SMTP_SSL(smtp_host, port, timeout=15)
+ else:
+ server = smtplib.SMTP(smtp_host, port, timeout=15)
+ server.ehlo()
+ server.starttls()
+ server.ehlo()
+
+ server.login(smtp_user, smtp_password)
+ server.sendmail(smtp_user, [to_email], msg.as_string())
+ server.quit()
+
+ logger.info(f"Correo de recuperación enviado con éxito a {to_email} vía SMTP")
+ return True
+ except Exception as e:
+ logger.error(f"Error enviando correo SMTP a {to_email}: {e}", exc_info=True)
+ if raise_on_error:
+ raise e
+ return False
+
+def datetime_year():
+ from datetime import datetime
+ return datetime.utcnow().year
diff --git a/app/utils/imgbb.py b/app/utils/imgbb.py
index d0374e314b1a5bed669c54e4dae8cd4c63d6c4f4..73011bf1b9304c4046a686ea0e0e8128ea11b208 100644
--- a/app/utils/imgbb.py
+++ b/app/utils/imgbb.py
@@ -1,76 +1,76 @@
-import requests
-import logging
-
-logger = logging.getLogger(__name__)
-
-IMGBB_UPLOAD_URL = "https://api.imgbb.com/1/upload"
-
-def upload_image_to_imgbb(base64_str: str, api_key: str = None) -> str:
- """
- Subir una imagen en formato base64 a ImgBB usando la clave de la iglesia y retornar la URL.
- Si ya es una URL válida, se retorna directamente.
- """
- if not base64_str:
- return base64_str
-
- if base64_str.startswith("http://") or base64_str.startswith("https://"):
- return base64_str
-
- if not api_key or not api_key.strip():
- logger.error("Intento de subida de imagen sin clave de ImgBB configurada.")
- raise ValueError("La iglesia no tiene configurada una clave de almacenamiento de imágenes (ImgBB). Por favor, pide al administrador que la configure en Conexiones.")
-
- try:
- # Remover prefijos como 'data:image/jpeg;base64,' si existen
- clean_base64 = base64_str
- if "," in base64_str:
- clean_base64 = base64_str.split(",", 1)[1]
-
- payload = {
- "key": api_key.strip(),
- "image": clean_base64
- }
-
- response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=20)
-
- if response.status_code in [400, 403]:
- logger.error(f"Clave ImgBB inválida o rechazada: {response.text}")
- raise ValueError("La clave de ImgBB configurada en esta iglesia ya no es válida o ha caducado.")
-
- response.raise_for_status()
- data = response.json()
- if data.get("success"):
- return data["data"]["url"]
- else:
- logger.error(f"Error subiendo a ImgBB: {data}")
- raise ValueError("No se pudo completar la subida de la imagen a ImgBB.")
-
- except ValueError as ve:
- raise ve
- except Exception as e:
- logger.error(f"Excepción subiendo a ImgBB: {e}")
- raise ValueError("Ocurrió un error de conexión al subir la imagen.")
-
-def validate_imgbb_key(api_key: str) -> bool:
- """
- Valida una clave de ImgBB subiendo un pixel PNG 1x1 estándar.
- """
- if not api_key or not api_key.strip():
- return False
-
- # Pixel PNG 1x1 en Base64
- tiny_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
-
- payload = {
- "key": api_key.strip(),
- "image": tiny_png
- }
-
- try:
- response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=10)
- if response.status_code == 200 and response.json().get("success"):
- return True
- return False
- except Exception as e:
- logger.warning(f"Fallo en validación de clave ImgBB: {e}")
- return False
+import requests
+import logging
+
+logger = logging.getLogger(__name__)
+
+IMGBB_UPLOAD_URL = "https://api.imgbb.com/1/upload"
+
+def upload_image_to_imgbb(base64_str: str, api_key: str = None) -> str:
+ """
+ Subir una imagen en formato base64 a ImgBB usando la clave de la iglesia y retornar la URL.
+ Si ya es una URL válida, se retorna directamente.
+ """
+ if not base64_str:
+ return base64_str
+
+ if base64_str.startswith("http://") or base64_str.startswith("https://"):
+ return base64_str
+
+ if not api_key or not api_key.strip():
+ logger.error("Intento de subida de imagen sin clave de ImgBB configurada.")
+ raise ValueError("La iglesia no tiene configurada una clave de almacenamiento de imágenes (ImgBB). Por favor, pide al administrador que la configure en Conexiones.")
+
+ try:
+ # Remover prefijos como 'data:image/jpeg;base64,' si existen
+ clean_base64 = base64_str
+ if "," in base64_str:
+ clean_base64 = base64_str.split(",", 1)[1]
+
+ payload = {
+ "key": api_key.strip(),
+ "image": clean_base64
+ }
+
+ response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=20)
+
+ if response.status_code in [400, 403]:
+ logger.error(f"Clave ImgBB inválida o rechazada: {response.text}")
+ raise ValueError("La clave de ImgBB configurada en esta iglesia ya no es válida o ha caducado.")
+
+ response.raise_for_status()
+ data = response.json()
+ if data.get("success"):
+ return data["data"]["url"]
+ else:
+ logger.error(f"Error subiendo a ImgBB: {data}")
+ raise ValueError("No se pudo completar la subida de la imagen a ImgBB.")
+
+ except ValueError as ve:
+ raise ve
+ except Exception as e:
+ logger.error(f"Excepción subiendo a ImgBB: {e}")
+ raise ValueError("Ocurrió un error de conexión al subir la imagen.")
+
+def validate_imgbb_key(api_key: str) -> bool:
+ """
+ Valida una clave de ImgBB subiendo un pixel PNG 1x1 estándar.
+ """
+ if not api_key or not api_key.strip():
+ return False
+
+ # Pixel PNG 1x1 en Base64
+ tiny_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+
+ payload = {
+ "key": api_key.strip(),
+ "image": tiny_png
+ }
+
+ try:
+ response = requests.post(IMGBB_UPLOAD_URL, data=payload, timeout=10)
+ if response.status_code == 200 and response.json().get("success"):
+ return True
+ return False
+ except Exception as e:
+ logger.warning(f"Fallo en validación de clave ImgBB: {e}")
+ return False
diff --git a/app/utils/midi_parser.py b/app/utils/midi_parser.py
index e62d62ca3ed0b6453c9dd35f6330856ca35b7078..fbd92a937b233df4f6e917053e1a6e0e80cb3918 100644
--- a/app/utils/midi_parser.py
+++ b/app/utils/midi_parser.py
@@ -1,84 +1,84 @@
-import pretty_midi
-import os
-import logging
-
-logger = logging.getLogger(__name__)
-
-def parse_midi_file(filepath: str) -> dict:
- """
- Parsea un archivo MIDI (.mid o .midi) y extrae todas sus pistas, notas, tempo y clasificación GM.
- """
- if not os.path.exists(filepath):
- raise FileNotFoundError(f"No se encontró el archivo MIDI en: {filepath}")
-
- try:
- pm = pretty_midi.PrettyMIDI(filepath)
- tempo = pm.estimate_tempo()
- duration = pm.get_end_time()
-
- tracks = []
- for idx, inst in enumerate(pm.instruments):
- notes = []
- for note in inst.notes:
- notes.append({
- "note": pretty_midi.note_number_to_name(note.pitch),
- "midi": note.pitch,
- "start": round(note.start, 3),
- "duration": round(note.end - note.start, 3),
- "velocity": note.velocity
- })
-
- # Si no tiene notas, la ignoramos para mantener el payload limpio
- if not notes:
- continue
-
- if inst.is_drum:
- inst_type = "drums"
- name = "Batería / Percusión"
- else:
- prog = inst.program
- if 0 <= prog <= 7:
- inst_type = "piano"
- name = f"Piano (GM {prog})"
- elif 24 <= prog <= 31:
- inst_type = "guitar"
- name = f"Guitarra (GM {prog})"
- elif 32 <= prog <= 39:
- inst_type = "bass"
- name = f"Bajo (GM {prog})"
- elif 16 <= prog <= 23:
- inst_type = "organ"
- name = f"Órgano (GM {prog})"
- elif 40 <= prog <= 55:
- inst_type = "strings"
- name = f"Cuerdas/Orquesta (GM {prog})"
- elif 56 <= prog <= 71:
- inst_type = "brass"
- name = f"Metales/Vientos (GM {prog})"
- else:
- inst_type = "synth"
- name = f"Sintetizador (GM {prog})"
-
- tracks.append({
- "id": idx,
- "nombre": name,
- "program": inst.program,
- "is_drum": inst.is_drum,
- "tipo_instrumento": inst_type,
- "total_notas": len(notes),
- "notas": notes
- })
-
- return {
- "success": True,
- "tempo_bpm": round(tempo, 1) if tempo > 0 else 120.0,
- "duracion_segundos": round(duration, 2),
- "total_pistas": len(tracks),
- "pistas": tracks
- }
- except Exception as e:
- logger.error(f"Error parseando archivo MIDI {filepath}: {e}")
- return {
- "success": False,
- "error": f"El archivo no es un MIDI válido o está dañado: {str(e)}"
- }
+import pretty_midi
+import os
+import logging
+
+logger = logging.getLogger(__name__)
+
+def parse_midi_file(filepath: str) -> dict:
+ """
+ Parsea un archivo MIDI (.mid o .midi) y extrae todas sus pistas, notas, tempo y clasificación GM.
+ """
+ if not os.path.exists(filepath):
+ raise FileNotFoundError(f"No se encontró el archivo MIDI en: {filepath}")
+
+ try:
+ pm = pretty_midi.PrettyMIDI(filepath)
+ tempo = pm.estimate_tempo()
+ duration = pm.get_end_time()
+
+ tracks = []
+ for idx, inst in enumerate(pm.instruments):
+ notes = []
+ for note in inst.notes:
+ notes.append({
+ "note": pretty_midi.note_number_to_name(note.pitch),
+ "midi": note.pitch,
+ "start": round(note.start, 3),
+ "duration": round(note.end - note.start, 3),
+ "velocity": note.velocity
+ })
+
+ # Si no tiene notas, la ignoramos para mantener el payload limpio
+ if not notes:
+ continue
+
+ if inst.is_drum:
+ inst_type = "drums"
+ name = "Batería / Percusión"
+ else:
+ prog = inst.program
+ if 0 <= prog <= 7:
+ inst_type = "piano"
+ name = f"Piano (GM {prog})"
+ elif 24 <= prog <= 31:
+ inst_type = "guitar"
+ name = f"Guitarra (GM {prog})"
+ elif 32 <= prog <= 39:
+ inst_type = "bass"
+ name = f"Bajo (GM {prog})"
+ elif 16 <= prog <= 23:
+ inst_type = "organ"
+ name = f"Órgano (GM {prog})"
+ elif 40 <= prog <= 55:
+ inst_type = "strings"
+ name = f"Cuerdas/Orquesta (GM {prog})"
+ elif 56 <= prog <= 71:
+ inst_type = "brass"
+ name = f"Metales/Vientos (GM {prog})"
+ else:
+ inst_type = "synth"
+ name = f"Sintetizador (GM {prog})"
+
+ tracks.append({
+ "id": idx,
+ "nombre": name,
+ "program": inst.program,
+ "is_drum": inst.is_drum,
+ "tipo_instrumento": inst_type,
+ "total_notas": len(notes),
+ "notas": notes
+ })
+
+ return {
+ "success": True,
+ "tempo_bpm": round(tempo, 1) if tempo > 0 else 120.0,
+ "duracion_segundos": round(duration, 2),
+ "total_pistas": len(tracks),
+ "pistas": tracks
+ }
+ except Exception as e:
+ logger.error(f"Error parseando archivo MIDI {filepath}: {e}")
+ return {
+ "success": False,
+ "error": f"El archivo no es un MIDI válido o está dañado: {str(e)}"
+ }
diff --git a/app/utils/notifications.py b/app/utils/notifications.py
index 198d5db6ebeaff3948dc4024eb30ad68747b2071..417d2c7a2bca3c1ed42ced37cf2d70d85cb9cdba 100644
--- a/app/utils/notifications.py
+++ b/app/utils/notifications.py
@@ -1,27 +1,27 @@
-from sqlalchemy.orm import Session
-from app.models.user import AdminNotification
-from datetime import datetime
-import logging
-
-logger = logging.getLogger(__name__)
-
-def create_admin_notification(db: Session, title: str, message: str, category: str = "system"):
- """
- Registra de forma segura una notificación para el administrador en la DB.
- Maneja excepciones y realiza rollback en caso de fallo para no romper la transacción principal.
- Categorías disponibles: system | user | church | group | worker
- """
- try:
- notif = AdminNotification(
- title=title,
- message=message,
- category=category,
- created_at=datetime.utcnow(),
- read=False
- )
- db.add(notif)
- db.commit()
- logger.info(f"[AdminNotif] Notificación registrada: {title} - {message}")
- except Exception as e:
- db.rollback()
- logger.warning(f"[AdminNotif] Error al guardar notificación de admin: {e}")
+from sqlalchemy.orm import Session
+from app.models.user import AdminNotification
+from datetime import datetime
+import logging
+
+logger = logging.getLogger(__name__)
+
+def create_admin_notification(db: Session, title: str, message: str, category: str = "system"):
+ """
+ Registra de forma segura una notificación para el administrador en la DB.
+ Maneja excepciones y realiza rollback en caso de fallo para no romper la transacción principal.
+ Categorías disponibles: system | user | church | group | worker
+ """
+ try:
+ notif = AdminNotification(
+ title=title,
+ message=message,
+ category=category,
+ created_at=datetime.utcnow(),
+ read=False
+ )
+ db.add(notif)
+ db.commit()
+ logger.info(f"[AdminNotif] Notificación registrada: {title} - {message}")
+ except Exception as e:
+ db.rollback()
+ logger.warning(f"[AdminNotif] Error al guardar notificación de admin: {e}")
diff --git a/app/utils/redis_client.py b/app/utils/redis_client.py
index 984807612558a47d8b21acf64d0cecb8e52ef562..1a06f5d84567a4f585bd7e88530913fcb89f1286 100644
--- a/app/utils/redis_client.py
+++ b/app/utils/redis_client.py
@@ -1,29 +1,29 @@
-import os
-import redis
-import logging
-
-logger = logging.getLogger(__name__)
-
-def get_redis_client():
- redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
-
- # Handle TLS for Upstash Redis
- _is_tls = redis_url.startswith("rediss://")
- if _is_tls:
- # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
- if "ssl_cert_reqs=" in redis_url:
- redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
- redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
- else:
- if "?" in redis_url:
- redis_url += "&ssl_cert_reqs=none"
- else:
- redis_url += "?ssl_cert_reqs=none"
-
- try:
- # If it's rediss://, redis.from_url handles ssl parameters
- client = redis.Redis.from_url(redis_url, decode_responses=True)
- return client
- except Exception as e:
- logger.error(f"Error creating Redis client: {e}")
- return None
+import os
+import redis
+import logging
+
+logger = logging.getLogger(__name__)
+
+def get_redis_client():
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
+
+ # Handle TLS for Upstash Redis
+ _is_tls = redis_url.startswith("rediss://")
+ if _is_tls:
+ # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
+ if "ssl_cert_reqs=" in redis_url:
+ redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
+ redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
+ else:
+ if "?" in redis_url:
+ redis_url += "&ssl_cert_reqs=none"
+ else:
+ redis_url += "?ssl_cert_reqs=none"
+
+ try:
+ # If it's rediss://, redis.from_url handles ssl parameters
+ client = redis.Redis.from_url(redis_url, decode_responses=True)
+ return client
+ except Exception as e:
+ logger.error(f"Error creating Redis client: {e}")
+ return None
diff --git a/app/utils/supabase_storage.py b/app/utils/supabase_storage.py
index 91bf621c6ec0f68893af9b8b87629a9cf5af0b4c..0d562afff3cef9687aad0079304bbaac4a70c81f 100644
--- a/app/utils/supabase_storage.py
+++ b/app/utils/supabase_storage.py
@@ -1,56 +1,56 @@
-import re
-import logging
-from supabase import create_client, Client
-
-logger = logging.getLogger("uvicorn.error")
-
-SUPABASE_URL = "https://egkydqberhebzkqtltic.supabase.co"
-SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVna3lkcWJlcmhlYnprcXRsdGljIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkzNDA4NTMsImV4cCI6MjA5NDkxNjg1M30.KfLrhTHrC2W2g6IDuGuMA5Gd_wnj4YOuRdV6hhcSCqM"
-BUCKET_NAME = "melodix-audio"
-
-def get_supabase_client() -> Client:
- return create_client(SUPABASE_URL, SUPABASE_KEY)
-
-def parse_supabase_path(url: str) -> str:
- """
- Extrae el path del archivo dentro del bucket a partir de la URL pública de Supabase.
- Ej: https://.../public/melodix-audio/originals/xxx.mp3 -> originals/xxx.mp3
- """
- if not url:
- return None
- pattern = rf"/storage/v1/object/public/{BUCKET_NAME}/(.+)$"
- match = re.search(pattern, url)
- if match:
- return match.group(1)
- return None
-
-def delete_supabase_file(path_in_bucket: str):
- """
- Elimina un archivo específico del bucket de Supabase.
- """
- if not path_in_bucket:
- return
- try:
- client = get_supabase_client()
- res = client.storage.from_(BUCKET_NAME).remove([path_in_bucket])
- logger.info(f"Archivo eliminado de Supabase: {path_in_bucket}. Resultado: {res}")
- except Exception as e:
- logger.warning(f"No se pudo eliminar archivo de Supabase ({path_in_bucket}): {e}")
-
-def delete_supabase_folder(folder_path: str):
- """
- Elimina todos los archivos dentro de una carpeta del bucket.
- """
- if not folder_path:
- return
- try:
- client = get_supabase_client()
- # Listar archivos en la carpeta
- files = client.storage.from_(BUCKET_NAME).list(folder_path)
- if files:
- paths_to_remove = [f"{folder_path}/{f['name']}" for f in files if f.get('name') and f['name'] != '.emptyFolderPlaceholder']
- if paths_to_remove:
- res = client.storage.from_(BUCKET_NAME).remove(paths_to_remove)
- logger.info(f"Carpeta eliminada de Supabase: {folder_path}. Archivos eliminados: {paths_to_remove}. Resultado: {res}")
- except Exception as e:
- logger.warning(f"No se pudo eliminar carpeta de Supabase ({folder_path}): {e}")
+import re
+import logging
+from supabase import create_client, Client
+
+logger = logging.getLogger("uvicorn.error")
+
+SUPABASE_URL = "https://egkydqberhebzkqtltic.supabase.co"
+SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVna3lkcWJlcmhlYnprcXRsdGljIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkzNDA4NTMsImV4cCI6MjA5NDkxNjg1M30.KfLrhTHrC2W2g6IDuGuMA5Gd_wnj4YOuRdV6hhcSCqM"
+BUCKET_NAME = "melodix-audio"
+
+def get_supabase_client() -> Client:
+ return create_client(SUPABASE_URL, SUPABASE_KEY)
+
+def parse_supabase_path(url: str) -> str:
+ """
+ Extrae el path del archivo dentro del bucket a partir de la URL pública de Supabase.
+ Ej: https://.../public/melodix-audio/originals/xxx.mp3 -> originals/xxx.mp3
+ """
+ if not url:
+ return None
+ pattern = rf"/storage/v1/object/public/{BUCKET_NAME}/(.+)$"
+ match = re.search(pattern, url)
+ if match:
+ return match.group(1)
+ return None
+
+def delete_supabase_file(path_in_bucket: str):
+ """
+ Elimina un archivo específico del bucket de Supabase.
+ """
+ if not path_in_bucket:
+ return
+ try:
+ client = get_supabase_client()
+ res = client.storage.from_(BUCKET_NAME).remove([path_in_bucket])
+ logger.info(f"Archivo eliminado de Supabase: {path_in_bucket}. Resultado: {res}")
+ except Exception as e:
+ logger.warning(f"No se pudo eliminar archivo de Supabase ({path_in_bucket}): {e}")
+
+def delete_supabase_folder(folder_path: str):
+ """
+ Elimina todos los archivos dentro de una carpeta del bucket.
+ """
+ if not folder_path:
+ return
+ try:
+ client = get_supabase_client()
+ # Listar archivos en la carpeta
+ files = client.storage.from_(BUCKET_NAME).list(folder_path)
+ if files:
+ paths_to_remove = [f"{folder_path}/{f['name']}" for f in files if f.get('name') and f['name'] != '.emptyFolderPlaceholder']
+ if paths_to_remove:
+ res = client.storage.from_(BUCKET_NAME).remove(paths_to_remove)
+ logger.info(f"Carpeta eliminada de Supabase: {folder_path}. Archivos eliminados: {paths_to_remove}. Resultado: {res}")
+ except Exception as e:
+ logger.warning(f"No se pudo eliminar carpeta de Supabase ({folder_path}): {e}")
diff --git a/app/utils/telegram_storage.py b/app/utils/telegram_storage.py
index 043cd273f6c61e2761b6dc2fd21f0df6850a30ba..902f9f926a49ffde90e559a566d36c1779893879 100644
--- a/app/utils/telegram_storage.py
+++ b/app/utils/telegram_storage.py
@@ -1,161 +1,161 @@
-# ==========================================
-# MELODIX - Telegram Storage Utility
-# Gestiona subida/descarga de stems al canal de Telegram desde el backend.
-# El Bot Token NUNCA se expone al cliente móvil.
-# ==========================================
-
-import os
-import logging
-import requests
-from dotenv import load_dotenv
-
-load_dotenv()
-
-logger = logging.getLogger(__name__)
-
-TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
-TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
-
-TELEGRAM_API = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
-TELEGRAM_FILE_API = f"https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}"
-
-
-def _check_config():
- if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
- raise RuntimeError(
- "TELEGRAM_BOT_TOKEN y TELEGRAM_CHAT_ID deben estar configurados en .env"
- )
-
-
-def upload_stem_to_telegram(file_path: str, stem_name: str, task_id: str) -> dict:
- """
- Sube un archivo FLAC al canal de Telegram con reintentos y tolerancia a límite de peticiones (429).
-
- Returns:
- {"file_id": str, "message_id": int}
-
- Raises:
- RuntimeError si falla la subida después de todos los reintentos.
- """
- import time as _time
- _check_config()
-
- if not os.path.exists(file_path):
- raise FileNotFoundError(f"Stem no encontrado para subir: {file_path}")
-
- file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
- logger.info(f"[Telegram] Subiendo stem '{stem_name}' de tarea '{task_id}' ({file_size_mb:.1f} MB)...")
-
- caption = f"🎵 task:{task_id} | stem:{stem_name}"
- max_retries = 3
-
- for attempt in range(1, max_retries + 1):
- try:
- with open(file_path, "rb") as f:
- response = requests.post(
- f"{TELEGRAM_API}/sendDocument",
- data={
- "chat_id": TELEGRAM_CHAT_ID,
- "caption": caption,
- },
- files={
- "document": (f"{task_id}_{stem_name}.flac", f, "audio/flac"),
- },
- timeout=300, # 5 minutos por archivo
- )
-
- # Manejar límite de peticiones de Telegram
- if response.status_code == 429:
- retry_after = 5
- try:
- retry_after = int(response.headers.get("Retry-After", 5))
- except:
- pass
- logger.warning(f"[Telegram] Límite de peticiones (429) alcanzado para '{stem_name}'. Reintentando en {retry_after}s... (Intento {attempt}/{max_retries})")
- _time.sleep(retry_after)
- continue
-
- if not response.ok:
- raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
-
- data = response.json()
- if not data.get("ok"):
- raise RuntimeError(f"Error API: {data.get('description', 'desconocido')}")
-
- document = data["result"].get("document") or data["result"].get("audio")
- if not document:
- raise RuntimeError("La respuesta no contiene 'document'")
-
- file_id = document["file_id"]
- message_id = data["result"]["message_id"]
-
- logger.info(f"[Telegram] ✅ '{stem_name}' subido con éxito en el intento {attempt}. message_id={message_id}")
- return {"file_id": file_id, "message_id": message_id}
-
- except Exception as e:
- logger.warning(f"[Telegram] Error en intento {attempt}/{max_retries} para '{stem_name}': {e}")
- if attempt == max_retries:
- raise RuntimeError(f"No se pudo subir a Telegram después de {max_retries} intentos. Error: {e}")
- _time.sleep(3) # Espera corta antes de reintentar
-
-
-
-def get_telegram_file_url(file_id: str) -> str:
- """
- Obtiene la URL de descarga directa (temporal ~1 hora) de un archivo en Telegram.
-
- Returns:
- URL de descarga directa (str)
-
- Raises:
- RuntimeError si falla.
- """
- _check_config()
-
- response = requests.get(
- f"{TELEGRAM_API}/getFile",
- params={"file_id": file_id},
- timeout=30,
- )
-
- if not response.ok:
- try:
- err_desc = response.json().get("description", response.text)
- except Exception:
- err_desc = response.text
- raise RuntimeError(f"[Telegram] getFile HTTP {response.status_code}: {err_desc}")
-
- data = response.json()
- if not data.get("ok"):
- raise RuntimeError(f"[Telegram] getFile error: {data.get('description')}")
-
- file_path = data["result"]["file_path"]
- return f"{TELEGRAM_FILE_API}/{file_path}"
-
-
-def delete_telegram_message(message_id: int) -> bool:
- """
- Elimina un mensaje (y por ende su archivo) del canal de Telegram.
- Llamar cuando el usuario borra una pista desde la app.
-
- Returns:
- True si se eliminó, False si hubo error no crítico.
- """
- _check_config()
-
- try:
- response = requests.post(
- f"{TELEGRAM_API}/deleteMessage",
- json={"chat_id": TELEGRAM_CHAT_ID, "message_id": message_id},
- timeout=15,
- )
- data = response.json()
- if data.get("ok"):
- logger.info(f"[Telegram] Mensaje {message_id} eliminado del canal.")
- return True
- else:
- logger.warning(f"[Telegram] No se pudo eliminar mensaje {message_id}: {data.get('description')}")
- return False
- except Exception as e:
- logger.warning(f"[Telegram] Error eliminando mensaje {message_id}: {e}")
- return False
+# ==========================================
+# MELODIX - Telegram Storage Utility
+# Gestiona subida/descarga de stems al canal de Telegram desde el backend.
+# El Bot Token NUNCA se expone al cliente móvil.
+# ==========================================
+
+import os
+import logging
+import requests
+from dotenv import load_dotenv
+
+load_dotenv()
+
+logger = logging.getLogger(__name__)
+
+TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
+TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
+
+TELEGRAM_API = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
+TELEGRAM_FILE_API = f"https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}"
+
+
+def _check_config():
+ if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
+ raise RuntimeError(
+ "TELEGRAM_BOT_TOKEN y TELEGRAM_CHAT_ID deben estar configurados en .env"
+ )
+
+
+def upload_stem_to_telegram(file_path: str, stem_name: str, task_id: str) -> dict:
+ """
+ Sube un archivo FLAC al canal de Telegram con reintentos y tolerancia a límite de peticiones (429).
+
+ Returns:
+ {"file_id": str, "message_id": int}
+
+ Raises:
+ RuntimeError si falla la subida después de todos los reintentos.
+ """
+ import time as _time
+ _check_config()
+
+ if not os.path.exists(file_path):
+ raise FileNotFoundError(f"Stem no encontrado para subir: {file_path}")
+
+ file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
+ logger.info(f"[Telegram] Subiendo stem '{stem_name}' de tarea '{task_id}' ({file_size_mb:.1f} MB)...")
+
+ caption = f"🎵 task:{task_id} | stem:{stem_name}"
+ max_retries = 3
+
+ for attempt in range(1, max_retries + 1):
+ try:
+ with open(file_path, "rb") as f:
+ response = requests.post(
+ f"{TELEGRAM_API}/sendDocument",
+ data={
+ "chat_id": TELEGRAM_CHAT_ID,
+ "caption": caption,
+ },
+ files={
+ "document": (f"{task_id}_{stem_name}.flac", f, "audio/flac"),
+ },
+ timeout=300, # 5 minutos por archivo
+ )
+
+ # Manejar límite de peticiones de Telegram
+ if response.status_code == 429:
+ retry_after = 5
+ try:
+ retry_after = int(response.headers.get("Retry-After", 5))
+ except:
+ pass
+ logger.warning(f"[Telegram] Límite de peticiones (429) alcanzado para '{stem_name}'. Reintentando en {retry_after}s... (Intento {attempt}/{max_retries})")
+ _time.sleep(retry_after)
+ continue
+
+ if not response.ok:
+ raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
+
+ data = response.json()
+ if not data.get("ok"):
+ raise RuntimeError(f"Error API: {data.get('description', 'desconocido')}")
+
+ document = data["result"].get("document") or data["result"].get("audio")
+ if not document:
+ raise RuntimeError("La respuesta no contiene 'document'")
+
+ file_id = document["file_id"]
+ message_id = data["result"]["message_id"]
+
+ logger.info(f"[Telegram] ✅ '{stem_name}' subido con éxito en el intento {attempt}. message_id={message_id}")
+ return {"file_id": file_id, "message_id": message_id}
+
+ except Exception as e:
+ logger.warning(f"[Telegram] Error en intento {attempt}/{max_retries} para '{stem_name}': {e}")
+ if attempt == max_retries:
+ raise RuntimeError(f"No se pudo subir a Telegram después de {max_retries} intentos. Error: {e}")
+ _time.sleep(3) # Espera corta antes de reintentar
+
+
+
+def get_telegram_file_url(file_id: str) -> str:
+ """
+ Obtiene la URL de descarga directa (temporal ~1 hora) de un archivo en Telegram.
+
+ Returns:
+ URL de descarga directa (str)
+
+ Raises:
+ RuntimeError si falla.
+ """
+ _check_config()
+
+ response = requests.get(
+ f"{TELEGRAM_API}/getFile",
+ params={"file_id": file_id},
+ timeout=30,
+ )
+
+ if not response.ok:
+ try:
+ err_desc = response.json().get("description", response.text)
+ except Exception:
+ err_desc = response.text
+ raise RuntimeError(f"[Telegram] getFile HTTP {response.status_code}: {err_desc}")
+
+ data = response.json()
+ if not data.get("ok"):
+ raise RuntimeError(f"[Telegram] getFile error: {data.get('description')}")
+
+ file_path = data["result"]["file_path"]
+ return f"{TELEGRAM_FILE_API}/{file_path}"
+
+
+def delete_telegram_message(message_id: int) -> bool:
+ """
+ Elimina un mensaje (y por ende su archivo) del canal de Telegram.
+ Llamar cuando el usuario borra una pista desde la app.
+
+ Returns:
+ True si se eliminó, False si hubo error no crítico.
+ """
+ _check_config()
+
+ try:
+ response = requests.post(
+ f"{TELEGRAM_API}/deleteMessage",
+ json={"chat_id": TELEGRAM_CHAT_ID, "message_id": message_id},
+ timeout=15,
+ )
+ data = response.json()
+ if data.get("ok"):
+ logger.info(f"[Telegram] Mensaje {message_id} eliminado del canal.")
+ return True
+ else:
+ logger.warning(f"[Telegram] No se pudo eliminar mensaje {message_id}: {data.get('description')}")
+ return False
+ except Exception as e:
+ logger.warning(f"[Telegram] Error eliminando mensaje {message_id}: {e}")
+ return False
diff --git a/app/websocket_manager.py b/app/websocket_manager.py
index 06aac5a092446a1d327def2d430482b701107c50..82e291d14d8ae65b0fa4de4d1338af6385bdcddd 100644
--- a/app/websocket_manager.py
+++ b/app/websocket_manager.py
@@ -1,65 +1,65 @@
-# ==========================================
-# MELODIX - WebSocket Connection Manager
-# ==========================================
-import asyncio
-from fastapi import WebSocket
-
-class ConnectionManager:
- def __init__(self):
- # Maps user_id (int) to their active WebSocket connection
- self.active_connections: dict[int, WebSocket] = {}
- self.loop = None
-
- async def connect(self, user_id: int, websocket: WebSocket):
- await websocket.accept()
- self.active_connections[user_id] = websocket
- self.loop = asyncio.get_running_loop()
- print(f"[WebSocket] User {user_id} connected. Active connections: {list(self.active_connections.keys())}")
-
- def disconnect(self, user_id: int):
- if user_id in self.active_connections:
- del self.active_connections[user_id]
- print(f"[WebSocket] User {user_id} disconnected.")
-
- async def send_personal_message(self, message: dict, user_id: int):
- if user_id in self.active_connections:
- try:
- await self.active_connections[user_id].send_json(message)
- print(f"[WebSocket] Sent message to user {user_id}: {message}")
- except Exception as e:
- print(f"[WebSocket] Error sending message to user {user_id}: {e}")
- self.disconnect(user_id)
-
- async def broadcast(self, message: dict):
- for user_id, connection in list(self.active_connections.items()):
- try:
- await connection.send_json(message)
- except Exception as e:
- print(f"[WebSocket] Error broadcasting to user {user_id}: {e}")
- self.disconnect(user_id)
-
-manager = ConnectionManager()
-
-def send_sync_message(message: dict, user_id: int):
- """Synchronous helper to send WebSocket messages from synchronous route threads."""
- try:
- if manager.loop is not None and manager.loop.is_running():
- # Call from synchronous route thread pool to the main running event loop in a thread-safe way
- asyncio.run_coroutine_threadsafe(manager.send_personal_message(message, user_id), manager.loop)
- else:
- # Fallback to current thread event loop
- try:
- loop = asyncio.get_event_loop()
- except RuntimeError:
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- if loop.is_running():
- # Call from running event loop (e.g. main thread)
- loop.create_task(manager.send_personal_message(message, user_id))
- else:
- # Call from synchronous route thread pool
- loop.run_until_complete(manager.send_personal_message(message, user_id))
- except Exception as e:
- print(f"[WebSocket] Error in send_sync_message for user {user_id}: {e}")
-
+# ==========================================
+# MELODIX - WebSocket Connection Manager
+# ==========================================
+import asyncio
+from fastapi import WebSocket
+
+class ConnectionManager:
+ def __init__(self):
+ # Maps user_id (int) to their active WebSocket connection
+ self.active_connections: dict[int, WebSocket] = {}
+ self.loop = None
+
+ async def connect(self, user_id: int, websocket: WebSocket):
+ await websocket.accept()
+ self.active_connections[user_id] = websocket
+ self.loop = asyncio.get_running_loop()
+ print(f"[WebSocket] User {user_id} connected. Active connections: {list(self.active_connections.keys())}")
+
+ def disconnect(self, user_id: int):
+ if user_id in self.active_connections:
+ del self.active_connections[user_id]
+ print(f"[WebSocket] User {user_id} disconnected.")
+
+ async def send_personal_message(self, message: dict, user_id: int):
+ if user_id in self.active_connections:
+ try:
+ await self.active_connections[user_id].send_json(message)
+ print(f"[WebSocket] Sent message to user {user_id}: {message}")
+ except Exception as e:
+ print(f"[WebSocket] Error sending message to user {user_id}: {e}")
+ self.disconnect(user_id)
+
+ async def broadcast(self, message: dict):
+ for user_id, connection in list(self.active_connections.items()):
+ try:
+ await connection.send_json(message)
+ except Exception as e:
+ print(f"[WebSocket] Error broadcasting to user {user_id}: {e}")
+ self.disconnect(user_id)
+
+manager = ConnectionManager()
+
+def send_sync_message(message: dict, user_id: int):
+ """Synchronous helper to send WebSocket messages from synchronous route threads."""
+ try:
+ if manager.loop is not None and manager.loop.is_running():
+ # Call from synchronous route thread pool to the main running event loop in a thread-safe way
+ asyncio.run_coroutine_threadsafe(manager.send_personal_message(message, user_id), manager.loop)
+ else:
+ # Fallback to current thread event loop
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ if loop.is_running():
+ # Call from running event loop (e.g. main thread)
+ loop.create_task(manager.send_personal_message(message, user_id))
+ else:
+ # Call from synchronous route thread pool
+ loop.run_until_complete(manager.send_personal_message(message, user_id))
+ except Exception as e:
+ print(f"[WebSocket] Error in send_sync_message for user {user_id}: {e}")
+
diff --git a/btc_toolkit/__init__.py b/btc_toolkit/__init__.py
index 9e85527b0ab321a291488920f865f2942030841e..9e7fd52a7c8f0ade4e15df44fb0ef3a6fe2eda3b 100644
--- a/btc_toolkit/__init__.py
+++ b/btc_toolkit/__init__.py
@@ -1,4 +1,4 @@
-# BTC Toolkit - Módulo de Reconocimiento de Acordes con Transformer Bidireccional
-from .inference import BTCChordRecognizer
-
-__all__ = ["BTCChordRecognizer"]
+# BTC Toolkit - Módulo de Reconocimiento de Acordes con Transformer Bidireccional
+from .inference import BTCChordRecognizer
+
+__all__ = ["BTCChordRecognizer"]
diff --git a/btc_toolkit/inference.py b/btc_toolkit/inference.py
index 9df8938a6cdd40615700b86629b1f21b8430e95e..859945750f28cc52e2cf5e6213bff3358b89f5fe 100644
--- a/btc_toolkit/inference.py
+++ b/btc_toolkit/inference.py
@@ -1,301 +1,301 @@
-"""
-BTC Toolkit - Motor de Inferencia de Acordes
-Carga el modelo BTC, preprocesa el audio y retorna los acordes detectados.
-
-Flujo:
- 1. Cargar audio con librosa (extracción de CQT).
- 2. Pasar por el modelo Transformer.
- 3. Decodificar predicciones a nombres de acordes Mayor/Menor.
- 4. Agrupar acordes consecutivos iguales en segmentos de tiempo.
-"""
-
-import os
-import logging
-import numpy as np
-import torch
-import torch.nn.functional as F
-
-from .model import BTCModel, CHORD_VOCAB
-from .weights_manager import ensure_weights_available
-
-logger = logging.getLogger(__name__)
-
-# ==========================================
-# PARÁMETROS DE AUDIO
-# ==========================================
-
-SAMPLE_RATE = 22050 # Hz
-HOP_LENGTH = 512 # Frames CQT
-N_BINS = 144 # Bins CQT (12 semitones x 12 octavas)
-BINS_PER_OCTAVE = 36 # Resolución octava
-SEGMENT_SECONDS = 30.0 # Longitud máxima por bloque (para VRAM limitada)
-MIN_CHORD_DURATION = 0.5 # Duración mínima de un acorde para mostrarlo (segundos)
-
-
-# ==========================================
-# CLASE PRINCIPAL DE INFERENCIA
-# ==========================================
-
-class BTCChordRecognizer:
- """
- Motor de reconocimiento de acordes usando BTC Transformer.
-
- Diseñado para bajo uso de VRAM:
- - Procesa el audio en bloques de 30 segundos.
- - Libera la VRAM al finalizar con `unload()`.
- """
-
- def __init__(self, device: str = "cuda"):
- self._model: BTCModel | None = None
- self._device_str = device
- self._device: torch.device | None = None
-
- def _resolve_device(self) -> torch.device:
- """Selecciona GPU si está disponible, sino CPU."""
- if self._device_str == "cuda" and torch.cuda.is_available():
- dev = torch.device("cuda")
- vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3)
- logger.info(f"[BTC] GPU detectada: {torch.cuda.get_device_name(0)} ({vram_gb:.1f} GB VRAM)")
- else:
- dev = torch.device("cpu")
- logger.info("[BTC] Usando CPU para inferencia de acordes.")
- return dev
-
- def load_model(self) -> None:
- """Carga el modelo en memoria. Descarga pesos si no están disponibles."""
- if self._model is not None:
- return
-
- self._device = self._resolve_device()
-
- # Asegurar que los pesos estén disponibles (descarga si falta)
- weights_path = ensure_weights_available()
-
- # Instanciar arquitectura
- self._model = BTCModel(n_freq=N_BINS)
- self._model.to(self._device)
-
- # Cargar pesos
- try:
- checkpoint = torch.load(weights_path, map_location=self._device, weights_only=False)
-
- # Soporte para checkpoints con y sin wrapper 'state_dict'
- if isinstance(checkpoint, dict) and "state_dict" in checkpoint:
- state = checkpoint["state_dict"]
- elif isinstance(checkpoint, dict) and "model_state_dict" in checkpoint:
- state = checkpoint["model_state_dict"]
- elif isinstance(checkpoint, dict) and "model" in checkpoint:
- state = checkpoint["model"]
- else:
- state = checkpoint
-
- # Carga flexible: ignora claves que no coinciden
- missing, unexpected = self._model.load_state_dict(state, strict=False)
- if missing:
- logger.warning(f"[BTC] Parámetros faltantes al cargar pesos: {len(missing)} parámetros faltantes")
- if unexpected:
- logger.debug(f"[BTC] Parámetros inesperados ignorados: {len(unexpected)} parámetros inesperados")
-
- self._model.eval()
- logger.info("[BTC] Modelo BTC cargado y listo para inferencia.")
-
- except Exception as e:
- self._model = None
- raise RuntimeError(f"[BTC] Error al cargar los pesos del modelo: {e}") from e
-
- def unload(self) -> None:
- """Libera el modelo de la memoria GPU para dejar espacio a otros procesos."""
- if self._model is not None:
- self._model.cpu()
- del self._model
- self._model = None
-
- if torch.cuda.is_available():
- torch.cuda.empty_cache()
- logger.info("[BTC] VRAM liberada correctamente.")
-
- def recognize(self, audio_path: str, bpm: float = None, beats: list = None) -> list[dict]:
- """
- Detecta acordes en un archivo de audio.
-
- Args:
- audio_path: Ruta al archivo de audio (.flac, .wav, .mp3).
-
- Returns:
- Lista de diccionarios con formato:
- [{"t": tiempo_inicio_segundos, "ch": "NombreAcorde"}, ...]
- """
- try:
- import librosa
- except ImportError:
- raise ImportError("librosa es requerido para la extracción de características CQT.")
-
- if not os.path.exists(audio_path):
- logger.error(f"[BTC] Archivo no encontrado: {audio_path}")
- return []
-
- # Asegurar que el modelo esté cargado
- if self._model is None:
- self.load_model()
-
- logger.info(f"[BTC] Analizando acordes en: {audio_path}")
-
- try:
- # ==========================================
- # EXTRACCIÓN DE CARACTERÍSTICAS CQT
- # ==========================================
- try:
- y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True)
- except Exception as load_err:
- logger.warning(f"[BTC] librosa.load falló ({load_err}), usando fallback soundfile/ffmpeg...")
- try:
- import soundfile as sf
- y, orig_sr = sf.read(audio_path, dtype='float32')
- if y.ndim > 1:
- y = np.mean(y, axis=1)
- if orig_sr != SAMPLE_RATE:
- try:
- import soxr
- y = soxr.resample(y, orig_sr, SAMPLE_RATE)
- except Exception:
- y = librosa.resample(y, orig_sr=orig_sr, target_sr=SAMPLE_RATE)
- sr = SAMPLE_RATE
- except Exception:
- from app.utils.harmony import _load_audio_via_ffmpeg
- y, sr = _load_audio_via_ffmpeg(audio_path, sr=SAMPLE_RATE)
-
- # Transformada de Q constante (CQT) - representación frecuencial de alta resolución
- # NOTA: Se ha omitido librosa.effects.harmonic por su altísimo costo en CPU
- cqt = np.abs(librosa.cqt(
- y,
- sr=sr,
- hop_length=HOP_LENGTH,
- n_bins=N_BINS,
- bins_per_octave=BINS_PER_OCTAVE,
- fmin=librosa.note_to_hz('C1')
- ))
-
- # Normalización logarítmica (aumenta contraste de frecuencias armónicas)
- cqt = librosa.amplitude_to_db(cqt, ref=np.max)
- cqt = (cqt - cqt.mean()) / (cqt.std() + 1e-8)
-
- # Transponer: (freq, time) -> (time, freq)
- cqt = cqt.T # shape: (T, N_BINS)
-
- # ==========================================
- # INFERENCIA POR BLOQUES (30s para VRAM 4GB)
- # ==========================================
- frames_per_second = sr / HOP_LENGTH
- block_size = int(SEGMENT_SECONDS * frames_per_second)
- all_probs = []
-
- with torch.no_grad():
- for start in range(0, len(cqt), block_size):
- block = cqt[start: start + block_size]
- block_tensor = torch.tensor(block, dtype=torch.float32).unsqueeze(0)
- block_tensor = block_tensor.to(self._device)
-
- logits = self._model(block_tensor)
- probs = F.softmax(logits, dim=-1).squeeze(0).cpu() # (N_frames, 171)
- all_probs.append(probs)
-
- # Concatenar probabilidades de toda la canción
- full_probs = torch.cat(all_probs, dim=0).numpy()
-
- # ==========================================
- # DECODIFICACIÓN, POOLING Y AGRUPACIÓN
- # ==========================================
- acordes_agrupados = _decode_and_group_chords(full_probs, frames_per_second, bpm, beats)
-
- logger.info(f"[BTC] ✅ Reconocimiento completado: {len(acordes_agrupados)} segmentos de acordes detectados.")
- return acordes_agrupados
-
- except Exception as e:
- logger.error(f"[BTC] Error durante la inferencia: {e}", exc_info=True)
- return []
- finally:
- # Siempre liberar VRAM al terminar, incluso si hubo error
- self.unload()
-
-
-# ==========================================
-# FUNCIÓN DE AGRUPACIÓN Y POOLING RÍTMICO
-# ==========================================
-
-def _decode_and_group_chords(probs: np.ndarray, fps: float, bpm: float = None, beats: list = None) -> list[dict]:
- """
- Toma un tensor de probabilidades y extrae los acordes.
-
- Estrategia: Emite un acorde por cada beat donde se detecta un cambio.
- El frontend hereda el acorde del beat anterior para beats sin cambio,
- garantizando que cada compás tenga un acorde visible.
-
- Cuando el modelo predice 'N' (no-chord), se toma el mejor acorde no-N
- para evitar compases vacíos.
- """
- total_frames = len(probs)
- N_IDX = 0 # 'N' es siempre el índice 0 en CHORD_VOCAB
-
- # ── Determinar las fronteras de cada beat ──
- if beats is not None and len(beats) > 1:
- beat_times = list(beats)
- elif bpm is not None and bpm > 40:
- beat_duration = 60.0 / bpm
- total_duration = total_frames / fps
- beat_times = [i * beat_duration for i in range(int(total_duration / beat_duration) + 1)]
- else:
- # Sin BPM conocido, usar beats de 0.5s
- total_duration = total_frames / fps
- beat_times = [i * 0.5 for i in range(int(total_duration / 0.5) + 1)]
-
- if not beat_times:
- return []
-
- # ── Poolear probabilidades por beat y obtener el mejor acorde no-N ──
- beat_chords = []
- for i, bt in enumerate(beat_times):
- # Calcular rango de frames para este beat
- next_bt = beat_times[i + 1] if i + 1 < len(beat_times) else total_frames / fps
- start_frame = int(bt * fps)
- end_frame = min(int(next_bt * fps), total_frames)
-
- if start_frame >= total_frames or start_frame >= end_frame:
- continue
-
- # Promediar probabilidades en el rango del beat
- beat_probs = np.mean(probs[start_frame:end_frame], axis=0)
-
- # Obtener el mejor acorde, evitando 'N'
- best_idx = np.argmax(beat_probs)
- if best_idx == N_IDX:
- # Buscar el mejor no-N
- beat_probs_no_n = beat_probs.copy()
- beat_probs_no_n[N_IDX] = -1
- best_idx = np.argmax(beat_probs_no_n)
- # Si el mejor no-N tiene probabilidad muy baja, usar N
- if beat_probs[best_idx] < 0.02:
- best_idx = N_IDX
-
- chord_name = CHORD_VOCAB[best_idx]
- beat_chords.append((round(bt, 3), chord_name))
-
- # ── Emitir un acorde solo cuando cambia (optimización) ──
- # El frontend hereda el acorde anterior para beats sin cambio,
- # así que solo necesitamos emitir en los puntos de cambio.
- result = []
- prev_chord = None
-
- for bt, chord in beat_chords:
- if chord == 'N':
- continue # Saltar los pocos frames que aún predicen N
- if chord != prev_chord:
- result.append({"t": round(bt, 2), "ch": chord})
- prev_chord = chord
-
- # Asegurar que siempre hay un acorde al inicio (t=0)
- if result and result[0]["t"] > 0.5:
- # Si el primer acorde está muy lejos del inicio, insertar uno al inicio
- result.insert(0, {"t": 0.0, "ch": result[0]["ch"]})
-
- return result
-
+"""
+BTC Toolkit - Motor de Inferencia de Acordes
+Carga el modelo BTC, preprocesa el audio y retorna los acordes detectados.
+
+Flujo:
+ 1. Cargar audio con librosa (extracción de CQT).
+ 2. Pasar por el modelo Transformer.
+ 3. Decodificar predicciones a nombres de acordes Mayor/Menor.
+ 4. Agrupar acordes consecutivos iguales en segmentos de tiempo.
+"""
+
+import os
+import logging
+import numpy as np
+import torch
+import torch.nn.functional as F
+
+from .model import BTCModel, CHORD_VOCAB
+from .weights_manager import ensure_weights_available
+
+logger = logging.getLogger(__name__)
+
+# ==========================================
+# PARÁMETROS DE AUDIO
+# ==========================================
+
+SAMPLE_RATE = 22050 # Hz
+HOP_LENGTH = 512 # Frames CQT
+N_BINS = 144 # Bins CQT (12 semitones x 12 octavas)
+BINS_PER_OCTAVE = 36 # Resolución octava
+SEGMENT_SECONDS = 30.0 # Longitud máxima por bloque (para VRAM limitada)
+MIN_CHORD_DURATION = 0.5 # Duración mínima de un acorde para mostrarlo (segundos)
+
+
+# ==========================================
+# CLASE PRINCIPAL DE INFERENCIA
+# ==========================================
+
+class BTCChordRecognizer:
+ """
+ Motor de reconocimiento de acordes usando BTC Transformer.
+
+ Diseñado para bajo uso de VRAM:
+ - Procesa el audio en bloques de 30 segundos.
+ - Libera la VRAM al finalizar con `unload()`.
+ """
+
+ def __init__(self, device: str = "cuda"):
+ self._model: BTCModel | None = None
+ self._device_str = device
+ self._device: torch.device | None = None
+
+ def _resolve_device(self) -> torch.device:
+ """Selecciona GPU si está disponible, sino CPU."""
+ if self._device_str == "cuda" and torch.cuda.is_available():
+ dev = torch.device("cuda")
+ vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3)
+ logger.info(f"[BTC] GPU detectada: {torch.cuda.get_device_name(0)} ({vram_gb:.1f} GB VRAM)")
+ else:
+ dev = torch.device("cpu")
+ logger.info("[BTC] Usando CPU para inferencia de acordes.")
+ return dev
+
+ def load_model(self) -> None:
+ """Carga el modelo en memoria. Descarga pesos si no están disponibles."""
+ if self._model is not None:
+ return
+
+ self._device = self._resolve_device()
+
+ # Asegurar que los pesos estén disponibles (descarga si falta)
+ weights_path = ensure_weights_available()
+
+ # Instanciar arquitectura
+ self._model = BTCModel(n_freq=N_BINS)
+ self._model.to(self._device)
+
+ # Cargar pesos
+ try:
+ checkpoint = torch.load(weights_path, map_location=self._device, weights_only=False)
+
+ # Soporte para checkpoints con y sin wrapper 'state_dict'
+ if isinstance(checkpoint, dict) and "state_dict" in checkpoint:
+ state = checkpoint["state_dict"]
+ elif isinstance(checkpoint, dict) and "model_state_dict" in checkpoint:
+ state = checkpoint["model_state_dict"]
+ elif isinstance(checkpoint, dict) and "model" in checkpoint:
+ state = checkpoint["model"]
+ else:
+ state = checkpoint
+
+ # Carga flexible: ignora claves que no coinciden
+ missing, unexpected = self._model.load_state_dict(state, strict=False)
+ if missing:
+ logger.warning(f"[BTC] Parámetros faltantes al cargar pesos: {len(missing)} parámetros faltantes")
+ if unexpected:
+ logger.debug(f"[BTC] Parámetros inesperados ignorados: {len(unexpected)} parámetros inesperados")
+
+ self._model.eval()
+ logger.info("[BTC] Modelo BTC cargado y listo para inferencia.")
+
+ except Exception as e:
+ self._model = None
+ raise RuntimeError(f"[BTC] Error al cargar los pesos del modelo: {e}") from e
+
+ def unload(self) -> None:
+ """Libera el modelo de la memoria GPU para dejar espacio a otros procesos."""
+ if self._model is not None:
+ self._model.cpu()
+ del self._model
+ self._model = None
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ logger.info("[BTC] VRAM liberada correctamente.")
+
+ def recognize(self, audio_path: str, bpm: float = None, beats: list = None) -> list[dict]:
+ """
+ Detecta acordes en un archivo de audio.
+
+ Args:
+ audio_path: Ruta al archivo de audio (.flac, .wav, .mp3).
+
+ Returns:
+ Lista de diccionarios con formato:
+ [{"t": tiempo_inicio_segundos, "ch": "NombreAcorde"}, ...]
+ """
+ try:
+ import librosa
+ except ImportError:
+ raise ImportError("librosa es requerido para la extracción de características CQT.")
+
+ if not os.path.exists(audio_path):
+ logger.error(f"[BTC] Archivo no encontrado: {audio_path}")
+ return []
+
+ # Asegurar que el modelo esté cargado
+ if self._model is None:
+ self.load_model()
+
+ logger.info(f"[BTC] Analizando acordes en: {audio_path}")
+
+ try:
+ # ==========================================
+ # EXTRACCIÓN DE CARACTERÍSTICAS CQT
+ # ==========================================
+ try:
+ y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True)
+ except Exception as load_err:
+ logger.warning(f"[BTC] librosa.load falló ({load_err}), usando fallback soundfile/ffmpeg...")
+ try:
+ import soundfile as sf
+ y, orig_sr = sf.read(audio_path, dtype='float32')
+ if y.ndim > 1:
+ y = np.mean(y, axis=1)
+ if orig_sr != SAMPLE_RATE:
+ try:
+ import soxr
+ y = soxr.resample(y, orig_sr, SAMPLE_RATE)
+ except Exception:
+ y = librosa.resample(y, orig_sr=orig_sr, target_sr=SAMPLE_RATE)
+ sr = SAMPLE_RATE
+ except Exception:
+ from app.utils.harmony import _load_audio_via_ffmpeg
+ y, sr = _load_audio_via_ffmpeg(audio_path, sr=SAMPLE_RATE)
+
+ # Transformada de Q constante (CQT) - representación frecuencial de alta resolución
+ # NOTA: Se ha omitido librosa.effects.harmonic por su altísimo costo en CPU
+ cqt = np.abs(librosa.cqt(
+ y,
+ sr=sr,
+ hop_length=HOP_LENGTH,
+ n_bins=N_BINS,
+ bins_per_octave=BINS_PER_OCTAVE,
+ fmin=librosa.note_to_hz('C1')
+ ))
+
+ # Normalización logarítmica (aumenta contraste de frecuencias armónicas)
+ cqt = librosa.amplitude_to_db(cqt, ref=np.max)
+ cqt = (cqt - cqt.mean()) / (cqt.std() + 1e-8)
+
+ # Transponer: (freq, time) -> (time, freq)
+ cqt = cqt.T # shape: (T, N_BINS)
+
+ # ==========================================
+ # INFERENCIA POR BLOQUES (30s para VRAM 4GB)
+ # ==========================================
+ frames_per_second = sr / HOP_LENGTH
+ block_size = int(SEGMENT_SECONDS * frames_per_second)
+ all_probs = []
+
+ with torch.no_grad():
+ for start in range(0, len(cqt), block_size):
+ block = cqt[start: start + block_size]
+ block_tensor = torch.tensor(block, dtype=torch.float32).unsqueeze(0)
+ block_tensor = block_tensor.to(self._device)
+
+ logits = self._model(block_tensor)
+ probs = F.softmax(logits, dim=-1).squeeze(0).cpu() # (N_frames, 171)
+ all_probs.append(probs)
+
+ # Concatenar probabilidades de toda la canción
+ full_probs = torch.cat(all_probs, dim=0).numpy()
+
+ # ==========================================
+ # DECODIFICACIÓN, POOLING Y AGRUPACIÓN
+ # ==========================================
+ acordes_agrupados = _decode_and_group_chords(full_probs, frames_per_second, bpm, beats)
+
+ logger.info(f"[BTC] ✅ Reconocimiento completado: {len(acordes_agrupados)} segmentos de acordes detectados.")
+ return acordes_agrupados
+
+ except Exception as e:
+ logger.error(f"[BTC] Error durante la inferencia: {e}", exc_info=True)
+ return []
+ finally:
+ # Siempre liberar VRAM al terminar, incluso si hubo error
+ self.unload()
+
+
+# ==========================================
+# FUNCIÓN DE AGRUPACIÓN Y POOLING RÍTMICO
+# ==========================================
+
+def _decode_and_group_chords(probs: np.ndarray, fps: float, bpm: float = None, beats: list = None) -> list[dict]:
+ """
+ Toma un tensor de probabilidades y extrae los acordes.
+
+ Estrategia: Emite un acorde por cada beat donde se detecta un cambio.
+ El frontend hereda el acorde del beat anterior para beats sin cambio,
+ garantizando que cada compás tenga un acorde visible.
+
+ Cuando el modelo predice 'N' (no-chord), se toma el mejor acorde no-N
+ para evitar compases vacíos.
+ """
+ total_frames = len(probs)
+ N_IDX = 0 # 'N' es siempre el índice 0 en CHORD_VOCAB
+
+ # ── Determinar las fronteras de cada beat ──
+ if beats is not None and len(beats) > 1:
+ beat_times = list(beats)
+ elif bpm is not None and bpm > 40:
+ beat_duration = 60.0 / bpm
+ total_duration = total_frames / fps
+ beat_times = [i * beat_duration for i in range(int(total_duration / beat_duration) + 1)]
+ else:
+ # Sin BPM conocido, usar beats de 0.5s
+ total_duration = total_frames / fps
+ beat_times = [i * 0.5 for i in range(int(total_duration / 0.5) + 1)]
+
+ if not beat_times:
+ return []
+
+ # ── Poolear probabilidades por beat y obtener el mejor acorde no-N ──
+ beat_chords = []
+ for i, bt in enumerate(beat_times):
+ # Calcular rango de frames para este beat
+ next_bt = beat_times[i + 1] if i + 1 < len(beat_times) else total_frames / fps
+ start_frame = int(bt * fps)
+ end_frame = min(int(next_bt * fps), total_frames)
+
+ if start_frame >= total_frames or start_frame >= end_frame:
+ continue
+
+ # Promediar probabilidades en el rango del beat
+ beat_probs = np.mean(probs[start_frame:end_frame], axis=0)
+
+ # Obtener el mejor acorde, evitando 'N'
+ best_idx = np.argmax(beat_probs)
+ if best_idx == N_IDX:
+ # Buscar el mejor no-N
+ beat_probs_no_n = beat_probs.copy()
+ beat_probs_no_n[N_IDX] = -1
+ best_idx = np.argmax(beat_probs_no_n)
+ # Si el mejor no-N tiene probabilidad muy baja, usar N
+ if beat_probs[best_idx] < 0.02:
+ best_idx = N_IDX
+
+ chord_name = CHORD_VOCAB[best_idx]
+ beat_chords.append((round(bt, 3), chord_name))
+
+ # ── Emitir un acorde solo cuando cambia (optimización) ──
+ # El frontend hereda el acorde anterior para beats sin cambio,
+ # así que solo necesitamos emitir en los puntos de cambio.
+ result = []
+ prev_chord = None
+
+ for bt, chord in beat_chords:
+ if chord == 'N':
+ continue # Saltar los pocos frames que aún predicen N
+ if chord != prev_chord:
+ result.append({"t": round(bt, 2), "ch": chord})
+ prev_chord = chord
+
+ # Asegurar que siempre hay un acorde al inicio (t=0)
+ if result and result[0]["t"] > 0.5:
+ # Si el primer acorde está muy lejos del inicio, insertar uno al inicio
+ result.insert(0, {"t": 0.0, "ch": result[0]["ch"]})
+
+ return result
+
diff --git a/btc_toolkit/weights_manager.py b/btc_toolkit/weights_manager.py
index 20e80d2766844759ca4cc18c95d72f55d6cc1333..5c5ac6eab22c503496252d62ff445f552ff8b67d 100644
--- a/btc_toolkit/weights_manager.py
+++ b/btc_toolkit/weights_manager.py
@@ -1,132 +1,132 @@
-"""
-BTC Toolkit - Descarga Automática de Pesos del Modelo
-Gestiona la descarga de los pesos pre-entrenados del modelo BTC desde GitHub.
-Incluye:
-- Descarga automática solo si no existen localmente.
-- Reporte de éxito/fallo al logger.
-- Verificación de integridad con SHA256.
-"""
-
-import os
-import hashlib
-import logging
-import urllib.request
-
-logger = logging.getLogger(__name__)
-
-# ==========================================
-# CONFIGURACIÓN DE PESOS
-# ==========================================
-
-# Directorio donde se guardan los pesos (relativo a este archivo)
-_MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "models")
-
-# URL pública del checkpoint pre-entrenado oficial.
-# Apunta al checkpoint completo de 12.2 MB alojado en Hugging Face (amaai-lab/music2emo)
-BTC_WEIGHTS_URL = "https://huggingface.co/amaai-lab/music2emo/resolve/main/inference/data/btc_model.pt"
-
-# Nombre local del archivo de pesos
-BTC_WEIGHTS_FILENAME = "btc_chord.pth"
-
-# Hash SHA256 del archivo esperado (para verificar integridad)
-BTC_WEIGHTS_SHA256 = "71c2c5db17e8c43b8a9a9da5db36ef2d667158c07a214eba16344c154c00bf54"
-
-
-def get_weights_path() -> str:
- """Retorna la ruta absoluta donde deben estar los pesos."""
- return os.path.join(_MODELS_DIR, BTC_WEIGHTS_FILENAME)
-
-
-def _verify_sha256(filepath: str, expected_hash: str) -> bool:
- """Verifica la integridad del archivo descargado."""
- sha256 = hashlib.sha256()
- with open(filepath, "rb") as f:
- for chunk in iter(lambda: f.read(8192), b""):
- sha256.update(chunk)
- return sha256.hexdigest() == expected_hash
-
-
-def ensure_weights_available() -> str:
- """
- Verifica si los pesos del modelo están disponibles localmente.
- Si no existen, los descarga automáticamente.
-
- Returns:
- str: Ruta absoluta al archivo de pesos.
-
- Raises:
- RuntimeError: Si la descarga falla.
- """
- weights_path = get_weights_path()
-
- # Crear directorio si no existe
- os.makedirs(_MODELS_DIR, exist_ok=True)
-
- # Si ya existen, no hace nada
- if os.path.exists(weights_path):
- logger.debug(f"[BTC] Pesos del modelo encontrados localmente: {weights_path}")
- return weights_path
-
- # --- Descarga automática ---
- logger.info(f"[BTC] Pesos no encontrados. Iniciando descarga automática...")
- logger.info(f"[BTC] URL: {BTC_WEIGHTS_URL}")
- logger.info(f"[BTC] Destino: {weights_path}")
-
- try:
- headers = {
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
- }
- hf_token = os.getenv("HF_TOKEN")
- if hf_token:
- headers["Authorization"] = f"Bearer {hf_token}"
-
- req = urllib.request.Request(BTC_WEIGHTS_URL, headers=headers)
- with urllib.request.urlopen(req, timeout=60) as response, open(weights_path, 'wb') as out_file:
- total_size = int(response.headers.get('content-length', 0))
- downloaded = 0
- block_size = 65536
- while True:
- chunk = response.read(block_size)
- if not chunk:
- break
- out_file.write(chunk)
- downloaded += len(chunk)
- if total_size > 0:
- percent = min(100, int(downloaded * 100 / total_size))
- if percent % 25 == 0:
- logger.debug(f"[BTC] Descargando: {percent}% ({downloaded / (1024*1024):.1f} MB / {total_size / (1024*1024):.1f} MB)")
-
- # Verificar que el archivo se descargó correctamente
- if not os.path.exists(weights_path) or os.path.getsize(weights_path) == 0:
- raise RuntimeError("El archivo descargado está vacío o no existe.")
-
- file_size_mb = os.path.getsize(weights_path) / (1024 * 1024)
-
- # Verificar integridad si hay hash disponible
- if BTC_WEIGHTS_SHA256:
- if _verify_sha256(weights_path, BTC_WEIGHTS_SHA256):
- logger.info(f"[BTC] ✅ Pesos descargados y verificados correctamente ({file_size_mb:.1f} MB)")
- else:
- os.remove(weights_path)
- raise RuntimeError("La verificación de integridad del archivo falló (SHA256 no coincide). El archivo fue eliminado.")
- else:
- logger.info(f"[BTC] ✅ Pesos descargados correctamente ({file_size_mb:.1f} MB) → {weights_path}")
-
- return weights_path
-
- except Exception as e:
- # Si falló la descarga, limpiar archivo parcial
- if os.path.exists(weights_path):
- try:
- os.remove(weights_path)
- except:
- pass
-
- error_msg = (
- f"[BTC] ❌ ERROR: No se pudieron descargar los pesos del modelo.\n"
- f" Razón: {str(e)}\n"
- f" URL intentada: {BTC_WEIGHTS_URL}\n"
- f" Verifica tu conexión a internet o descarga el archivo manualmente en: {weights_path}"
- )
- logger.error(error_msg)
- raise RuntimeError(error_msg) from e
+"""
+BTC Toolkit - Descarga Automática de Pesos del Modelo
+Gestiona la descarga de los pesos pre-entrenados del modelo BTC desde GitHub.
+Incluye:
+- Descarga automática solo si no existen localmente.
+- Reporte de éxito/fallo al logger.
+- Verificación de integridad con SHA256.
+"""
+
+import os
+import hashlib
+import logging
+import urllib.request
+
+logger = logging.getLogger(__name__)
+
+# ==========================================
+# CONFIGURACIÓN DE PESOS
+# ==========================================
+
+# Directorio donde se guardan los pesos (relativo a este archivo)
+_MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "models")
+
+# URL pública del checkpoint pre-entrenado oficial.
+# Apunta al checkpoint completo de 12.2 MB alojado en Hugging Face (amaai-lab/music2emo)
+BTC_WEIGHTS_URL = "https://huggingface.co/amaai-lab/music2emo/resolve/main/inference/data/btc_model.pt"
+
+# Nombre local del archivo de pesos
+BTC_WEIGHTS_FILENAME = "btc_chord.pth"
+
+# Hash SHA256 del archivo esperado (para verificar integridad)
+BTC_WEIGHTS_SHA256 = "71c2c5db17e8c43b8a9a9da5db36ef2d667158c07a214eba16344c154c00bf54"
+
+
+def get_weights_path() -> str:
+ """Retorna la ruta absoluta donde deben estar los pesos."""
+ return os.path.join(_MODELS_DIR, BTC_WEIGHTS_FILENAME)
+
+
+def _verify_sha256(filepath: str, expected_hash: str) -> bool:
+ """Verifica la integridad del archivo descargado."""
+ sha256 = hashlib.sha256()
+ with open(filepath, "rb") as f:
+ for chunk in iter(lambda: f.read(8192), b""):
+ sha256.update(chunk)
+ return sha256.hexdigest() == expected_hash
+
+
+def ensure_weights_available() -> str:
+ """
+ Verifica si los pesos del modelo están disponibles localmente.
+ Si no existen, los descarga automáticamente.
+
+ Returns:
+ str: Ruta absoluta al archivo de pesos.
+
+ Raises:
+ RuntimeError: Si la descarga falla.
+ """
+ weights_path = get_weights_path()
+
+ # Crear directorio si no existe
+ os.makedirs(_MODELS_DIR, exist_ok=True)
+
+ # Si ya existen, no hace nada
+ if os.path.exists(weights_path):
+ logger.debug(f"[BTC] Pesos del modelo encontrados localmente: {weights_path}")
+ return weights_path
+
+ # --- Descarga automática ---
+ logger.info(f"[BTC] Pesos no encontrados. Iniciando descarga automática...")
+ logger.info(f"[BTC] URL: {BTC_WEIGHTS_URL}")
+ logger.info(f"[BTC] Destino: {weights_path}")
+
+ try:
+ headers = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ }
+ hf_token = os.getenv("HF_TOKEN")
+ if hf_token:
+ headers["Authorization"] = f"Bearer {hf_token}"
+
+ req = urllib.request.Request(BTC_WEIGHTS_URL, headers=headers)
+ with urllib.request.urlopen(req, timeout=60) as response, open(weights_path, 'wb') as out_file:
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded = 0
+ block_size = 65536
+ while True:
+ chunk = response.read(block_size)
+ if not chunk:
+ break
+ out_file.write(chunk)
+ downloaded += len(chunk)
+ if total_size > 0:
+ percent = min(100, int(downloaded * 100 / total_size))
+ if percent % 25 == 0:
+ logger.debug(f"[BTC] Descargando: {percent}% ({downloaded / (1024*1024):.1f} MB / {total_size / (1024*1024):.1f} MB)")
+
+ # Verificar que el archivo se descargó correctamente
+ if not os.path.exists(weights_path) or os.path.getsize(weights_path) == 0:
+ raise RuntimeError("El archivo descargado está vacío o no existe.")
+
+ file_size_mb = os.path.getsize(weights_path) / (1024 * 1024)
+
+ # Verificar integridad si hay hash disponible
+ if BTC_WEIGHTS_SHA256:
+ if _verify_sha256(weights_path, BTC_WEIGHTS_SHA256):
+ logger.info(f"[BTC] ✅ Pesos descargados y verificados correctamente ({file_size_mb:.1f} MB)")
+ else:
+ os.remove(weights_path)
+ raise RuntimeError("La verificación de integridad del archivo falló (SHA256 no coincide). El archivo fue eliminado.")
+ else:
+ logger.info(f"[BTC] ✅ Pesos descargados correctamente ({file_size_mb:.1f} MB) → {weights_path}")
+
+ return weights_path
+
+ except Exception as e:
+ # Si falló la descarga, limpiar archivo parcial
+ if os.path.exists(weights_path):
+ try:
+ os.remove(weights_path)
+ except:
+ pass
+
+ error_msg = (
+ f"[BTC] ❌ ERROR: No se pudieron descargar los pesos del modelo.\n"
+ f" Razón: {str(e)}\n"
+ f" URL intentada: {BTC_WEIGHTS_URL}\n"
+ f" Verifica tu conexión a internet o descarga el archivo manualmente en: {weights_path}"
+ )
+ logger.error(error_msg)
+ raise RuntimeError(error_msg) from e
diff --git a/check_requirements.py b/check_requirements.py
index 8c104c15546ce22b15aa5d6cd3c3617d46f0fe67..b1deab30e873b8d2fcebb3a4d31e16bd835bbd7b 100644
--- a/check_requirements.py
+++ b/check_requirements.py
@@ -1,322 +1,322 @@
-"""
-MELODIX - Verificador de Prerrequisitos
-
-Verifica que todos los componentes necesarios estén instalados y configurados.
-
-Uso: python check_requirements.py
-"""
-
-import sys
-import os
-import subprocess
-import shutil
-from dotenv import load_dotenv
-
-# ==========================================
-# CONFIGURACIÓN
-# ==========================================
-
-load_dotenv()
-
-COLORS = {
- 'GREEN': '\033[92m',
- 'RED': '\033[91m',
- 'YELLOW': '\033[93m',
- 'BLUE': '\033[94m',
- 'END': '\033[0m'
-}
-
-def c(text, color):
- """Colorea el texto"""
- return f"{COLORS.get(color, '')}{text}{COLORS['END']}"
-
-def check_mark(passed):
- """Retorna ✓ o ✗"""
- return c("✓", "GREEN") if passed else c("✗", "RED")
-
-# ==========================================
-# VERIFICACIONES
-# ==========================================
-
-def check_python():
- """Verifica la versión de Python"""
- print("\n" + "=" * 50)
- print(" PYTHON")
- print("=" * 50)
-
- version = sys.version_info
- passed = version.major == 3 and version.minor >= 10
-
- print(f" {check_mark(passed)} Versión: {version.major}.{version.minor}.{version.micro}")
- print(f" {'→ Requiere Python 3.10+' if not passed else ''}")
-
- return passed
-
-def check_packages():
- """Verifica los paquetes Python instalados"""
- print("\n" + "=" * 50)
- print(" PAQUETES PYTHON")
- print("=" * 50)
-
- required_packages = {
- 'fastapi': 'FastAPI',
- 'uvicorn': 'Uvicorn',
- 'sqlalchemy': 'SQLAlchemy',
- 'celery': 'Celery',
- 'redis': 'Redis',
- 'librosa': 'Librosa',
- 'passlib': 'Passlib',
- 'jose': 'Python-JOSE',
- 'dotenv': 'python-dotenv',
- 'pydantic': 'Pydantic',
- }
-
- all_installed = True
-
- for package, name in required_packages.items():
- try:
- __import__(package)
- pkg = __import__(package)
- version = getattr(pkg, '__version__', 'N/A')
- print(f" {check_mark(True)} {name} ({version})")
- except ImportError:
- print(f" {check_mark(False)} {name} - NO INSTALADO")
- all_installed = False
-
- # Verificar demucs
- try:
- import demucs
- print(f" {check_mark(True)} Demucs ({demucs.__version__ if hasattr(demucs, '__version__') else 'N/A'})")
- except ImportError:
- print(f" {check_mark(False)} Demucs - NO INSTALADO")
- all_installed = False
-
- return all_installed
-
-def check_redis():
- """Verifica la conexión a Redis"""
- print("\n" + "=" * 50)
- print(" REDIS")
- print("=" * 50)
-
- try:
- import redis
- redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
-
- # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
- if redis_url.startswith("rediss://"):
- if "ssl_cert_reqs=" in redis_url:
- redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
- redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
- else:
- if "?" in redis_url:
- redis_url += "&ssl_cert_reqs=none"
- else:
- redis_url += "?ssl_cert_reqs=none"
-
- r = redis.from_url(redis_url)
- r.ping()
-
- print(f" {check_mark(True)} Conexión: {redis_url}")
- print(f" {check_mark(True)} Redis respondiendo: PONG")
-
- return True
-
- except ImportError:
- print(f" {check_mark(False)} Paquete redis NO INSTALADO")
- return False
- except Exception as e:
- print(f" {check_mark(False)} Conexión fallida: {e}")
- print(f" → ¿Está Redis corriendo? Ejecuta: redis-server")
- return False
-
-def check_ffmpeg():
- """Verifica FFmpeg"""
- print("\n" + "=" * 50)
- print(" FFMPEG")
- print("=" * 50)
-
- ffmpeg_path = shutil.which("ffmpeg")
-
- if ffmpeg_path:
- try:
- result = subprocess.run(
- ["ffmpeg", "-version"],
- capture_output=True,
- text=True,
- timeout=5
- )
- version_line = result.stdout.split('\n')[0]
- print(f" {check_mark(True)} FFmpeg encontrado: {ffmpeg_path}")
- print(f" {check_mark(True)} {version_line}")
- return True
- except Exception as e:
- print(f" {check_mark(False)} Error verificando versión: {e}")
- return False
- else:
- print(f" {check_mark(False)} FFmpeg NO ENCONTRADO en PATH")
- print(f" → Instala FFmpeg: https://ffmpeg.org/download.html")
- print(f" → O usa Chocolatey: choco install ffmpeg")
- return False
-
-def check_demucs_models():
- """Verifica los modelos de Demucs"""
- print("\n" + "=" * 50)
- print(" MODELOS DEMUCS")
- print("=" * 50)
-
- # Los modelos se descargan automáticamente la primera vez
- # Solo verificamos si existe el directorio de caché
- cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
-
- if os.path.exists(cache_dir):
- models = [d for d in os.listdir(cache_dir) if d.startswith('models--facebook--demucs')]
- if models:
- print(f" {check_mark(True)} Modelos en caché: {len(models)} encontrado(s)")
- for m in models:
- print(f" - {m}")
- return True
- else:
- print(f" {check_mark(True)} Caché listo (modelos se descargarán la primera vez)")
- return True
- else:
- print(f" {check_mark(True)} Los modelos se descargarán automáticamente")
- return True
-
-def check_directories():
- """Verifica los directorios necesarios"""
- print("\n" + "=" * 50)
- print(" DIRECTORIOS")
- print("=" * 50)
-
- base_dir = os.getenv("BASE_DIR", os.path.dirname(os.path.abspath(__file__)))
- dirs_to_check = [
- ("BASE_DIR", base_dir),
- ("UPLOADS_DIR", os.getenv("UPLOADS_DIR", os.path.join(base_dir, "uploads"))),
- ("PROCESSED_DIR", os.getenv("PROCESSED_DIR", os.path.join(base_dir, "processed"))),
- ("LOGS_DIR", os.path.join(base_dir, "logs")),
- ]
-
- all_ok = True
-
- for name, path in dirs_to_check:
- exists = os.path.exists(path)
- writable = os.access(path, os.W_OK) if exists else False
-
- if exists and writable:
- print(f" {check_mark(True)} {name}: {path}")
- elif exists:
- print(f" {check_mark(False)} {name}: {path} (NO ESCRIBIBLE)")
- all_ok = False
- else:
- print(f" {check_mark(False)} {name}: {path} (NO EXISTE)")
- all_ok = False
-
- return all_ok
-
-def check_env_file():
- """Verifica el archivo .env"""
- print("\n" + "=" * 50)
- print(" VARIABLES DE ENTORNO")
- print("=" * 50)
-
- env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
-
- if os.path.exists(env_file):
- print(f" {check_mark(True)} Archivo .env encontrado")
-
- # Verificar variables críticas
- critical_vars = ["SECRET_KEY", "REDIS_URL"]
- for var in critical_vars:
- value = os.getenv(var)
- if value:
- print(f" {check_mark(True)} {var}: configurado")
- else:
- print(f" {check_mark(False)} {var}: NO CONFIGURADO")
-
- return True
- else:
- print(f" {check_mark(False)} Archivo .env NO ENCONTRADO")
- print(f" → Copia .env.example a .env")
- return False
-
-def check_gpu():
- """Verifica disponibilidad de GPU CUDA"""
- print("\n" + "=" * 50)
- print(" GPU / CUDA")
- print("=" * 50)
-
- try:
- import torch
- cuda_available = torch.cuda.is_available()
-
- if cuda_available:
- gpu_name = torch.cuda.get_device_name(0)
- gpu_memory = torch.cuda.get_device_properties(0).total_memory / (1024**3)
- print(f" {check_mark(True)} CUDA disponible: {gpu_name}")
- print(f" {check_mark(True)} Memoria GPU: {gpu_memory:.1f} GB")
- return True
- else:
- print(f" {check_mark(False)} CUDA NO disponible")
- print(f" → Se usará CPU (más lento)")
- print(f" → Para GPU: instala PyTorch con CUDA")
- return False
-
- except ImportError:
- print(f" {check_mark(False)} PyTorch NO INSTALADO")
- print(f" → Demucs lo instalará como dependencia")
- return False
- except Exception as e:
- print(f" {check_mark(False)} Error verificando GPU: {e}")
- return False
-
-# ==========================================
-# MAIN
-# ==========================================
-
-def main():
- print(c("\n" + "=" * 60, "BLUE"))
- print(c(" MELODIX - VERIFICADOR DE PRERREQUISITOS", "BLUE"))
- print(c("=" * 60, "BLUE"))
-
- results = {
- "Python": check_python(),
- "Paquetes": check_packages(),
- "Redis": check_redis(),
- "FFmpeg": check_ffmpeg(),
- "Demucs": check_demucs_models(),
- "Directorios": check_directories(),
- ".env": check_env_file(),
- "GPU": check_gpu(),
- }
-
- # ==========================================
- # RESUMEN FINAL
- # ==========================================
- print("\n" + "=" * 60)
- print(c(" RESUMEN", "BLUE"))
- print("=" * 60)
-
- passed = sum(1 for v in results.values() if v)
- total = len(results)
-
- for name, result in results.items():
- status = c("✓ PASS", "GREEN") if result else c("✗ FAIL", "RED")
- print(f" {status} - {name}")
-
- print("\n" + "-" * 60)
-
- if passed == total:
- print(c(" ¡TODO LISTO! Puedes iniciar Melodix", "GREEN"))
- print(c(" Ejecuta: iniciar_melodix.bat", "GREEN"))
- else:
- print(c(f" ATENCIÓN: {total - passed} verificación(es) fallida(s)", "YELLOW"))
- print(c(" Revisa los errores arriba y corrígelos antes de iniciar", "YELLOW"))
-
- print("=" * 60 + "\n")
-
- return 0 if passed == total else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
+"""
+MELODIX - Verificador de Prerrequisitos
+
+Verifica que todos los componentes necesarios estén instalados y configurados.
+
+Uso: python check_requirements.py
+"""
+
+import sys
+import os
+import subprocess
+import shutil
+from dotenv import load_dotenv
+
+# ==========================================
+# CONFIGURACIÓN
+# ==========================================
+
+load_dotenv()
+
+COLORS = {
+ 'GREEN': '\033[92m',
+ 'RED': '\033[91m',
+ 'YELLOW': '\033[93m',
+ 'BLUE': '\033[94m',
+ 'END': '\033[0m'
+}
+
+def c(text, color):
+ """Colorea el texto"""
+ return f"{COLORS.get(color, '')}{text}{COLORS['END']}"
+
+def check_mark(passed):
+ """Retorna ✓ o ✗"""
+ return c("✓", "GREEN") if passed else c("✗", "RED")
+
+# ==========================================
+# VERIFICACIONES
+# ==========================================
+
+def check_python():
+ """Verifica la versión de Python"""
+ print("\n" + "=" * 50)
+ print(" PYTHON")
+ print("=" * 50)
+
+ version = sys.version_info
+ passed = version.major == 3 and version.minor >= 10
+
+ print(f" {check_mark(passed)} Versión: {version.major}.{version.minor}.{version.micro}")
+ print(f" {'→ Requiere Python 3.10+' if not passed else ''}")
+
+ return passed
+
+def check_packages():
+ """Verifica los paquetes Python instalados"""
+ print("\n" + "=" * 50)
+ print(" PAQUETES PYTHON")
+ print("=" * 50)
+
+ required_packages = {
+ 'fastapi': 'FastAPI',
+ 'uvicorn': 'Uvicorn',
+ 'sqlalchemy': 'SQLAlchemy',
+ 'celery': 'Celery',
+ 'redis': 'Redis',
+ 'librosa': 'Librosa',
+ 'passlib': 'Passlib',
+ 'jose': 'Python-JOSE',
+ 'dotenv': 'python-dotenv',
+ 'pydantic': 'Pydantic',
+ }
+
+ all_installed = True
+
+ for package, name in required_packages.items():
+ try:
+ __import__(package)
+ pkg = __import__(package)
+ version = getattr(pkg, '__version__', 'N/A')
+ print(f" {check_mark(True)} {name} ({version})")
+ except ImportError:
+ print(f" {check_mark(False)} {name} - NO INSTALADO")
+ all_installed = False
+
+ # Verificar demucs
+ try:
+ import demucs
+ print(f" {check_mark(True)} Demucs ({demucs.__version__ if hasattr(demucs, '__version__') else 'N/A'})")
+ except ImportError:
+ print(f" {check_mark(False)} Demucs - NO INSTALADO")
+ all_installed = False
+
+ return all_installed
+
+def check_redis():
+ """Verifica la conexión a Redis"""
+ print("\n" + "=" * 50)
+ print(" REDIS")
+ print("=" * 50)
+
+ try:
+ import redis
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
+
+ # Normalize ssl_cert_reqs to 'none' if it is 'CERT_NONE' (case-insensitive) to prevent redis-py errors
+ if redis_url.startswith("rediss://"):
+ if "ssl_cert_reqs=" in redis_url:
+ redis_url = redis_url.replace("ssl_cert_reqs=CERT_NONE", "ssl_cert_reqs=none")
+ redis_url = redis_url.replace("ssl_cert_reqs=cert_none", "ssl_cert_reqs=none")
+ else:
+ if "?" in redis_url:
+ redis_url += "&ssl_cert_reqs=none"
+ else:
+ redis_url += "?ssl_cert_reqs=none"
+
+ r = redis.from_url(redis_url)
+ r.ping()
+
+ print(f" {check_mark(True)} Conexión: {redis_url}")
+ print(f" {check_mark(True)} Redis respondiendo: PONG")
+
+ return True
+
+ except ImportError:
+ print(f" {check_mark(False)} Paquete redis NO INSTALADO")
+ return False
+ except Exception as e:
+ print(f" {check_mark(False)} Conexión fallida: {e}")
+ print(f" → ¿Está Redis corriendo? Ejecuta: redis-server")
+ return False
+
+def check_ffmpeg():
+ """Verifica FFmpeg"""
+ print("\n" + "=" * 50)
+ print(" FFMPEG")
+ print("=" * 50)
+
+ ffmpeg_path = shutil.which("ffmpeg")
+
+ if ffmpeg_path:
+ try:
+ result = subprocess.run(
+ ["ffmpeg", "-version"],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ version_line = result.stdout.split('\n')[0]
+ print(f" {check_mark(True)} FFmpeg encontrado: {ffmpeg_path}")
+ print(f" {check_mark(True)} {version_line}")
+ return True
+ except Exception as e:
+ print(f" {check_mark(False)} Error verificando versión: {e}")
+ return False
+ else:
+ print(f" {check_mark(False)} FFmpeg NO ENCONTRADO en PATH")
+ print(f" → Instala FFmpeg: https://ffmpeg.org/download.html")
+ print(f" → O usa Chocolatey: choco install ffmpeg")
+ return False
+
+def check_demucs_models():
+ """Verifica los modelos de Demucs"""
+ print("\n" + "=" * 50)
+ print(" MODELOS DEMUCS")
+ print("=" * 50)
+
+ # Los modelos se descargan automáticamente la primera vez
+ # Solo verificamos si existe el directorio de caché
+ cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
+
+ if os.path.exists(cache_dir):
+ models = [d for d in os.listdir(cache_dir) if d.startswith('models--facebook--demucs')]
+ if models:
+ print(f" {check_mark(True)} Modelos en caché: {len(models)} encontrado(s)")
+ for m in models:
+ print(f" - {m}")
+ return True
+ else:
+ print(f" {check_mark(True)} Caché listo (modelos se descargarán la primera vez)")
+ return True
+ else:
+ print(f" {check_mark(True)} Los modelos se descargarán automáticamente")
+ return True
+
+def check_directories():
+ """Verifica los directorios necesarios"""
+ print("\n" + "=" * 50)
+ print(" DIRECTORIOS")
+ print("=" * 50)
+
+ base_dir = os.getenv("BASE_DIR", os.path.dirname(os.path.abspath(__file__)))
+ dirs_to_check = [
+ ("BASE_DIR", base_dir),
+ ("UPLOADS_DIR", os.getenv("UPLOADS_DIR", os.path.join(base_dir, "uploads"))),
+ ("PROCESSED_DIR", os.getenv("PROCESSED_DIR", os.path.join(base_dir, "processed"))),
+ ("LOGS_DIR", os.path.join(base_dir, "logs")),
+ ]
+
+ all_ok = True
+
+ for name, path in dirs_to_check:
+ exists = os.path.exists(path)
+ writable = os.access(path, os.W_OK) if exists else False
+
+ if exists and writable:
+ print(f" {check_mark(True)} {name}: {path}")
+ elif exists:
+ print(f" {check_mark(False)} {name}: {path} (NO ESCRIBIBLE)")
+ all_ok = False
+ else:
+ print(f" {check_mark(False)} {name}: {path} (NO EXISTE)")
+ all_ok = False
+
+ return all_ok
+
+def check_env_file():
+ """Verifica el archivo .env"""
+ print("\n" + "=" * 50)
+ print(" VARIABLES DE ENTORNO")
+ print("=" * 50)
+
+ env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
+
+ if os.path.exists(env_file):
+ print(f" {check_mark(True)} Archivo .env encontrado")
+
+ # Verificar variables críticas
+ critical_vars = ["SECRET_KEY", "REDIS_URL"]
+ for var in critical_vars:
+ value = os.getenv(var)
+ if value:
+ print(f" {check_mark(True)} {var}: configurado")
+ else:
+ print(f" {check_mark(False)} {var}: NO CONFIGURADO")
+
+ return True
+ else:
+ print(f" {check_mark(False)} Archivo .env NO ENCONTRADO")
+ print(f" → Copia .env.example a .env")
+ return False
+
+def check_gpu():
+ """Verifica disponibilidad de GPU CUDA"""
+ print("\n" + "=" * 50)
+ print(" GPU / CUDA")
+ print("=" * 50)
+
+ try:
+ import torch
+ cuda_available = torch.cuda.is_available()
+
+ if cuda_available:
+ gpu_name = torch.cuda.get_device_name(0)
+ gpu_memory = torch.cuda.get_device_properties(0).total_memory / (1024**3)
+ print(f" {check_mark(True)} CUDA disponible: {gpu_name}")
+ print(f" {check_mark(True)} Memoria GPU: {gpu_memory:.1f} GB")
+ return True
+ else:
+ print(f" {check_mark(False)} CUDA NO disponible")
+ print(f" → Se usará CPU (más lento)")
+ print(f" → Para GPU: instala PyTorch con CUDA")
+ return False
+
+ except ImportError:
+ print(f" {check_mark(False)} PyTorch NO INSTALADO")
+ print(f" → Demucs lo instalará como dependencia")
+ return False
+ except Exception as e:
+ print(f" {check_mark(False)} Error verificando GPU: {e}")
+ return False
+
+# ==========================================
+# MAIN
+# ==========================================
+
+def main():
+ print(c("\n" + "=" * 60, "BLUE"))
+ print(c(" MELODIX - VERIFICADOR DE PRERREQUISITOS", "BLUE"))
+ print(c("=" * 60, "BLUE"))
+
+ results = {
+ "Python": check_python(),
+ "Paquetes": check_packages(),
+ "Redis": check_redis(),
+ "FFmpeg": check_ffmpeg(),
+ "Demucs": check_demucs_models(),
+ "Directorios": check_directories(),
+ ".env": check_env_file(),
+ "GPU": check_gpu(),
+ }
+
+ # ==========================================
+ # RESUMEN FINAL
+ # ==========================================
+ print("\n" + "=" * 60)
+ print(c(" RESUMEN", "BLUE"))
+ print("=" * 60)
+
+ passed = sum(1 for v in results.values() if v)
+ total = len(results)
+
+ for name, result in results.items():
+ status = c("✓ PASS", "GREEN") if result else c("✗ FAIL", "RED")
+ print(f" {status} - {name}")
+
+ print("\n" + "-" * 60)
+
+ if passed == total:
+ print(c(" ¡TODO LISTO! Puedes iniciar Melodix", "GREEN"))
+ print(c(" Ejecuta: iniciar_melodix.bat", "GREEN"))
+ else:
+ print(c(f" ATENCIÓN: {total - passed} verificación(es) fallida(s)", "YELLOW"))
+ print(c(" Revisa los errores arriba y corrígelos antes de iniciar", "YELLOW"))
+
+ print("=" * 60 + "\n")
+
+ return 0 if passed == total else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/check_schema.py b/check_schema.py
index a67460809b6f15f61ce3d174aa0d5358e645b568..4a84c5913ccaf4b170483f7b9d5dd6e04bef9f79 100644
--- a/check_schema.py
+++ b/check_schema.py
@@ -1,15 +1,15 @@
-import psycopg2
-
-DATABASE_URL = "postgresql://postgres.egkydqberhebzkqtltic:Isaac%23105*may%40@aws-1-us-east-1.pooler.supabase.com:5432/postgres"
-
-def check_schema():
- conn = psycopg2.connect(DATABASE_URL)
- cur = conn.cursor()
- cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name='devices';")
- cols = [r[0] for r in cur.fetchall()]
- print("Columnas en devices:", cols)
- cur.close()
- conn.close()
-
-if __name__ == "__main__":
- check_schema()
+import psycopg2
+
+DATABASE_URL = "postgresql://postgres.egkydqberhebzkqtltic:Isaac%23105*may%40@aws-1-us-east-1.pooler.supabase.com:5432/postgres"
+
+def check_schema():
+ conn = psycopg2.connect(DATABASE_URL)
+ cur = conn.cursor()
+ cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name='devices';")
+ cols = [r[0] for r in cur.fetchall()]
+ print("Columnas en devices:", cols)
+ cur.close()
+ conn.close()
+
+if __name__ == "__main__":
+ check_schema()
diff --git a/check_tasks.py b/check_tasks.py
index 118c7095b5228ba00af375f3d4c55ac6a29b3203..0728702ccebbd9511a4bdeed6194587855792982 100644
--- a/check_tasks.py
+++ b/check_tasks.py
@@ -1,28 +1,28 @@
-from sqlalchemy import create_engine, text
-
-DATABASE_URL = 'sqlite:///./musical_master.db'
-
-print("Connecting to Local SQLite...")
-try:
- engine = create_engine(DATABASE_URL)
- with engine.connect() as conn:
- print("--- Table Record Counts ---")
- tables = ["users", "tasks", "devices", "iglesias", "groups", "lyrics"]
- for table in tables:
- try:
- res = conn.execute(text(f"SELECT count(*) FROM {table};"))
- count = res.scalar()
- print(f"Table '{table}': {count} records")
- except Exception as e:
- print(f"Table '{table}' error: {e}")
-
- print("\n--- Recent Tasks in Local SQLite ---")
- try:
- res = conn.execute(text("SELECT id, task_id, song_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5;"))
- rows = res.fetchall()
- for r in rows:
- print(f"ID: {r[0]} | TaskID: {r[1]} | Song: {r[2]} | Status: {r[3]} | Created: {r[4]}")
- except Exception as e:
- print("Tasks error:", e)
-except Exception as e:
- print("Error:", e)
+from sqlalchemy import create_engine, text
+
+DATABASE_URL = 'sqlite:///./musical_master.db'
+
+print("Connecting to Local SQLite...")
+try:
+ engine = create_engine(DATABASE_URL)
+ with engine.connect() as conn:
+ print("--- Table Record Counts ---")
+ tables = ["users", "tasks", "devices", "iglesias", "groups", "lyrics"]
+ for table in tables:
+ try:
+ res = conn.execute(text(f"SELECT count(*) FROM {table};"))
+ count = res.scalar()
+ print(f"Table '{table}': {count} records")
+ except Exception as e:
+ print(f"Table '{table}' error: {e}")
+
+ print("\n--- Recent Tasks in Local SQLite ---")
+ try:
+ res = conn.execute(text("SELECT id, task_id, song_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5;"))
+ rows = res.fetchall()
+ for r in rows:
+ print(f"ID: {r[0]} | TaskID: {r[1]} | Song: {r[2]} | Status: {r[3]} | Created: {r[4]}")
+ except Exception as e:
+ print("Tasks error:", e)
+except Exception as e:
+ print("Error:", e)
diff --git a/cleanup.py b/cleanup.py
index 2a1d6939d37b8d5eafd794bc1cf95d2fc6c34799..d74a9535b4abde9f82422ce0ae40f85a4649e6d3 100644
--- a/cleanup.py
+++ b/cleanup.py
@@ -1,289 +1,289 @@
-"""
-MELODIX - Script de Limpieza Automática
-
-Este script limpia archivos temporales y procesados antiguos.
-Se recomienda ejecutarlo periódicamente (ej. diariamente).
-
-Uso: python cleanup.py [--days 7] [--dry-run]
-"""
-
-import os
-import sys
-import shutil
-import argparse
-import logging
-from datetime import datetime, timedelta
-from dotenv import load_dotenv
-
-# ==========================================
-# CONFIGURACIÓN
-# ==========================================
-
-load_dotenv()
-
-# Configurar logging
-logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s - %(levelname)s - %(message)s'
-)
-logger = logging.getLogger(__name__)
-
-# ==========================================
-# CONFIGURACIÓN DE DIRECTORIOS
-# ==========================================
-
-BASE_DIR = os.getenv("BASE_DIR", os.path.dirname(os.path.abspath(__file__)))
-UPLOADS_DIR = os.getenv("UPLOADS_DIR", os.path.join(BASE_DIR, "uploads"))
-PROCESSED_DIR = os.getenv("PROCESSED_DIR", os.path.join(BASE_DIR, "processed"))
-LOGS_DIR = os.path.join(BASE_DIR, "logs")
-
-# ==========================================
-# FUNCIONES DE LIMPIEZA
-# ==========================================
-
-def get_directory_size(path):
- """Calcula el tamaño total de un directorio en MB"""
- total_size = 0
- if os.path.exists(path):
- for dirpath, dirnames, filenames in os.walk(path):
- for f in filenames:
- fp = os.path.join(dirpath, f)
- if os.path.exists(fp):
- total_size += os.path.getsize(fp)
- return total_size / (1024 * 1024)
-
-
-def clean_uploads(max_age_days=1, dry_run=False):
- """
- Limpia archivos de uploads más antiguos que X días.
- Los archivos de uploads deberían eliminarse después de procesarse,
- pero esto es un respaldo por si algo falla.
- """
- logger.info(f"Limpiando uploads más antiguos de {max_age_days} días...")
-
- if not os.path.exists(UPLOADS_DIR):
- logger.info(f"Directorio uploads no existe: {UPLOADS_DIR}")
- return 0
-
- cutoff = datetime.now() - timedelta(days=max_age_days)
- cleaned = 0
- freed_mb = 0
-
- for filename in os.listdir(UPLOADS_DIR):
- filepath = os.path.join(UPLOADS_DIR, filename)
-
- if os.path.isfile(filepath):
- mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
-
- if mtime < cutoff:
- size_mb = os.path.getsize(filepath) / (1024 * 1024)
-
- if dry_run:
- logger.info(f" [DRY-RUN] Eliminaría: {filename} ({size_mb:.2f}MB)")
- else:
- try:
- os.remove(filepath)
- logger.info(f" Eliminado: {filename} ({size_mb:.2f}MB)")
- except Exception as e:
- logger.error(f" Error eliminando {filename}: {e}")
- continue
-
- cleaned += 1
- freed_mb += size_mb
-
- logger.info(f"Uploads limpiados: {cleaned} archivos, {freed_mb:.2f}MB liberados")
- return cleaned
-
-
-def clean_processed(max_age_days=7, dry_run=False):
- """
- Limpia archivos procesados más antiguos que X días.
- Los usuarios tienen una semana para descargar sus resultados.
- """
- logger.info(f"Limpiando procesados más antiguos de {max_age_days} días...")
-
- if not os.path.exists(PROCESSED_DIR):
- logger.info(f"Directorio processed no existe: {PROCESSED_DIR}")
- return 0
-
- cutoff = datetime.now() - timedelta(days=max_age_days)
- cleaned = 0
- freed_mb = 0
-
- for task_folder in os.listdir(PROCESSED_DIR):
- task_path = os.path.join(PROCESSED_DIR, task_folder)
-
- if os.path.isdir(task_path):
- # Obtener la fecha más reciente de cualquier archivo en la carpeta
- latest_mtime = 0
- for root, dirs, files in os.walk(task_path):
- for f in files:
- fp = os.path.join(root, f)
- if os.path.exists(fp):
- mtime = os.path.getmtime(fp)
- if mtime > latest_mtime:
- latest_mtime = mtime
-
- if latest_mtime > 0:
- folder_mtime = datetime.fromtimestamp(latest_mtime)
-
- if folder_mtime < cutoff:
- size_mb = get_directory_size(task_path)
-
- if dry_run:
- logger.info(f" [DRY-RUN] Eliminaría: {task_folder} ({size_mb:.2f}MB)")
- else:
- try:
- shutil.rmtree(task_path)
- logger.info(f" Eliminado: {task_folder} ({size_mb:.2f}MB)")
- except Exception as e:
- logger.error(f" Error eliminando {task_folder}: {e}")
- continue
-
- cleaned += 1
- freed_mb += size_mb
-
- logger.info(f"Procesados limpiados: {cleaned} carpetas, {freed_mb:.2f}MB liberados")
- return cleaned
-
-
-def clean_logs(max_age_days=30, dry_run=False):
- """
- Limpia archivos de log más antiguos que X días.
- El logging rotativo ya maneja el tamaño, esto es por seguridad.
- """
- logger.info(f"Limpiando logs más antiguos de {max_age_days} días...")
-
- if not os.path.exists(LOGS_DIR):
- logger.info(f"Directorio logs no existe: {LOGS_DIR}")
- return 0
-
- cutoff = datetime.now() - timedelta(days=max_age_days)
- cleaned = 0
-
- for filename in os.listdir(LOGS_DIR):
- filepath = os.path.join(LOGS_DIR, filename)
-
- if os.path.isfile(filepath) and filename.endswith('.log'):
- mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
-
- if mtime < cutoff:
- size_mb = os.path.getsize(filepath) / (1024 * 1024)
-
- if dry_run:
- logger.info(f" [DRY-RUN] Eliminaría: {filename} ({size_mb:.2f}MB)")
- else:
- # Mantener siempre el archivo actual
- if filename == "melodix.log":
- logger.info(f" Saltando log actual: {filename}")
- continue
-
- try:
- os.remove(filepath)
- logger.info(f" Eliminado: {filename} ({size_mb:.2f}MB)")
- except Exception as e:
- logger.error(f" Error eliminando {filename}: {e}")
- continue
-
- cleaned += 1
-
- logger.info(f"Logs limpiados: {cleaned} archivos")
- return cleaned
-
-
-def show_stats():
- """Muestra estadísticas de uso de disco"""
- logger.info("=" * 50)
- logger.info("ESTADÍSTICAS DE ALMACENAMIENTO")
- logger.info("=" * 50)
-
- uploads_mb = get_directory_size(UPLOADS_DIR)
- processed_mb = get_directory_size(PROCESSED_DIR)
- logs_mb = get_directory_size(LOGS_DIR)
-
- logger.info(f"Uploads: {uploads_mb:>10.2f} MB")
- logger.info(f"Processed: {processed_mb:>10.2f} MB")
- logger.info(f"Logs: {logs_mb:>10.2f} MB")
- logger.info(f"Total: {uploads_mb + processed_mb + logs_mb:>10.2f} MB")
- logger.info("=" * 50)
-
-
-# ==========================================
-# EXPIRACIÓN DE ALMACENAMIENTO EN HUGGING FACE
-# ==========================================
-
-def clean_expired_sync_queue(max_age_days=7, dry_run=False):
- """
- Ejecuta la limpieza de archivos expirados en Hugging Face Dataset
- y la purga mensual de usuarios normales si corresponde.
- """
- logger.info("Ejecutando limpieza de archivos expirados en Hugging Face Datasets...")
- if dry_run:
- logger.info(" [DRY-RUN] Ejecutaría la tarea de limpieza en Hugging Face.")
- return 0
-
- current_dir = os.path.dirname(os.path.abspath(__file__))
- if current_dir not in sys.path:
- sys.path.insert(0, current_dir)
-
- try:
- from tasks import limpiar_stems_expirados_task
- res = limpiar_stems_expirados_task()
- cleaned = res.get("cleaned", 0)
- logger.info(f"Limpieza en Hugging Face completada. Tareas limpiadas: {cleaned}")
- return cleaned
- except Exception as e:
- logger.error(f"Error ejecutando limpiar_stems_expirados_task desde cleanup.py: {e}")
- return 0
-
-
-# ==========================================
-# MAIN
-# ==========================================
-
-def main():
- parser = argparse.ArgumentParser(description="Limpieza automática de Melodix")
- parser.add_argument("--days", type=int, default=7, help="Días de antigüedad para limpiar (default: 7)")
- parser.add_argument("--dry-run", action="store_true", help="Solo mostrar qué se eliminaría")
- parser.add_argument("--stats", action="store_true", help="Mostrar estadísticas de uso")
-
- args = parser.parse_args()
-
- logger.info("=" * 50)
- logger.info("MELODIX - LIMPIEZA AUTOMÁTICA")
- logger.info("=" * 50)
- logger.info(f"Fecha: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
- logger.info(f"Modo: {'DRY-RUN' if args.dry_run else 'NORMAL'}")
- logger.info(f"Días de antigüedad: {args.days}")
- logger.info("=" * 50)
-
- if args.stats:
- show_stats()
-
- logger.info("")
- cleaned_uploads = clean_uploads(max_age_days=1, dry_run=args.dry_run) # Uploads: 1 día
- logger.info("")
- cleaned_processed = clean_processed(max_age_days=args.days, dry_run=args.dry_run) # Procesados: X días
- logger.info("")
- cleaned_logs = clean_logs(max_age_days=30, dry_run=args.dry_run) # Logs: 30 días
- logger.info("")
- cleaned_sync = clean_expired_sync_queue(max_age_days=args.days, dry_run=args.dry_run) # HF/Expira
-
- logger.info("")
- logger.info("=" * 50)
- logger.info("RESUMEN")
- logger.info("=" * 50)
- logger.info(f"Uploads limpiados: {cleaned_uploads}")
- logger.info(f"Procesados limpiados: {cleaned_processed}")
- logger.info(f"Logs limpiados: {cleaned_logs}")
- logger.info(f"HF/Nube limpiados: {cleaned_sync}")
- logger.info("=" * 50)
-
- if args.dry_run:
- logger.info("")
- logger.info("[DRY-RUN] No se eliminó ningún archivo")
- logger.info("Ejecuta sin --dry-run para aplicar la limpieza")
-
-
-if __name__ == "__main__":
- main()
+"""
+MELODIX - Script de Limpieza Automática
+
+Este script limpia archivos temporales y procesados antiguos.
+Se recomienda ejecutarlo periódicamente (ej. diariamente).
+
+Uso: python cleanup.py [--days 7] [--dry-run]
+"""
+
+import os
+import sys
+import shutil
+import argparse
+import logging
+from datetime import datetime, timedelta
+from dotenv import load_dotenv
+
+# ==========================================
+# CONFIGURACIÓN
+# ==========================================
+
+load_dotenv()
+
+# Configurar logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# ==========================================
+# CONFIGURACIÓN DE DIRECTORIOS
+# ==========================================
+
+BASE_DIR = os.getenv("BASE_DIR", os.path.dirname(os.path.abspath(__file__)))
+UPLOADS_DIR = os.getenv("UPLOADS_DIR", os.path.join(BASE_DIR, "uploads"))
+PROCESSED_DIR = os.getenv("PROCESSED_DIR", os.path.join(BASE_DIR, "processed"))
+LOGS_DIR = os.path.join(BASE_DIR, "logs")
+
+# ==========================================
+# FUNCIONES DE LIMPIEZA
+# ==========================================
+
+def get_directory_size(path):
+ """Calcula el tamaño total de un directorio en MB"""
+ total_size = 0
+ if os.path.exists(path):
+ for dirpath, dirnames, filenames in os.walk(path):
+ for f in filenames:
+ fp = os.path.join(dirpath, f)
+ if os.path.exists(fp):
+ total_size += os.path.getsize(fp)
+ return total_size / (1024 * 1024)
+
+
+def clean_uploads(max_age_days=1, dry_run=False):
+ """
+ Limpia archivos de uploads más antiguos que X días.
+ Los archivos de uploads deberían eliminarse después de procesarse,
+ pero esto es un respaldo por si algo falla.
+ """
+ logger.info(f"Limpiando uploads más antiguos de {max_age_days} días...")
+
+ if not os.path.exists(UPLOADS_DIR):
+ logger.info(f"Directorio uploads no existe: {UPLOADS_DIR}")
+ return 0
+
+ cutoff = datetime.now() - timedelta(days=max_age_days)
+ cleaned = 0
+ freed_mb = 0
+
+ for filename in os.listdir(UPLOADS_DIR):
+ filepath = os.path.join(UPLOADS_DIR, filename)
+
+ if os.path.isfile(filepath):
+ mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
+
+ if mtime < cutoff:
+ size_mb = os.path.getsize(filepath) / (1024 * 1024)
+
+ if dry_run:
+ logger.info(f" [DRY-RUN] Eliminaría: {filename} ({size_mb:.2f}MB)")
+ else:
+ try:
+ os.remove(filepath)
+ logger.info(f" Eliminado: {filename} ({size_mb:.2f}MB)")
+ except Exception as e:
+ logger.error(f" Error eliminando {filename}: {e}")
+ continue
+
+ cleaned += 1
+ freed_mb += size_mb
+
+ logger.info(f"Uploads limpiados: {cleaned} archivos, {freed_mb:.2f}MB liberados")
+ return cleaned
+
+
+def clean_processed(max_age_days=7, dry_run=False):
+ """
+ Limpia archivos procesados más antiguos que X días.
+ Los usuarios tienen una semana para descargar sus resultados.
+ """
+ logger.info(f"Limpiando procesados más antiguos de {max_age_days} días...")
+
+ if not os.path.exists(PROCESSED_DIR):
+ logger.info(f"Directorio processed no existe: {PROCESSED_DIR}")
+ return 0
+
+ cutoff = datetime.now() - timedelta(days=max_age_days)
+ cleaned = 0
+ freed_mb = 0
+
+ for task_folder in os.listdir(PROCESSED_DIR):
+ task_path = os.path.join(PROCESSED_DIR, task_folder)
+
+ if os.path.isdir(task_path):
+ # Obtener la fecha más reciente de cualquier archivo en la carpeta
+ latest_mtime = 0
+ for root, dirs, files in os.walk(task_path):
+ for f in files:
+ fp = os.path.join(root, f)
+ if os.path.exists(fp):
+ mtime = os.path.getmtime(fp)
+ if mtime > latest_mtime:
+ latest_mtime = mtime
+
+ if latest_mtime > 0:
+ folder_mtime = datetime.fromtimestamp(latest_mtime)
+
+ if folder_mtime < cutoff:
+ size_mb = get_directory_size(task_path)
+
+ if dry_run:
+ logger.info(f" [DRY-RUN] Eliminaría: {task_folder} ({size_mb:.2f}MB)")
+ else:
+ try:
+ shutil.rmtree(task_path)
+ logger.info(f" Eliminado: {task_folder} ({size_mb:.2f}MB)")
+ except Exception as e:
+ logger.error(f" Error eliminando {task_folder}: {e}")
+ continue
+
+ cleaned += 1
+ freed_mb += size_mb
+
+ logger.info(f"Procesados limpiados: {cleaned} carpetas, {freed_mb:.2f}MB liberados")
+ return cleaned
+
+
+def clean_logs(max_age_days=30, dry_run=False):
+ """
+ Limpia archivos de log más antiguos que X días.
+ El logging rotativo ya maneja el tamaño, esto es por seguridad.
+ """
+ logger.info(f"Limpiando logs más antiguos de {max_age_days} días...")
+
+ if not os.path.exists(LOGS_DIR):
+ logger.info(f"Directorio logs no existe: {LOGS_DIR}")
+ return 0
+
+ cutoff = datetime.now() - timedelta(days=max_age_days)
+ cleaned = 0
+
+ for filename in os.listdir(LOGS_DIR):
+ filepath = os.path.join(LOGS_DIR, filename)
+
+ if os.path.isfile(filepath) and filename.endswith('.log'):
+ mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
+
+ if mtime < cutoff:
+ size_mb = os.path.getsize(filepath) / (1024 * 1024)
+
+ if dry_run:
+ logger.info(f" [DRY-RUN] Eliminaría: {filename} ({size_mb:.2f}MB)")
+ else:
+ # Mantener siempre el archivo actual
+ if filename == "melodix.log":
+ logger.info(f" Saltando log actual: {filename}")
+ continue
+
+ try:
+ os.remove(filepath)
+ logger.info(f" Eliminado: {filename} ({size_mb:.2f}MB)")
+ except Exception as e:
+ logger.error(f" Error eliminando {filename}: {e}")
+ continue
+
+ cleaned += 1
+
+ logger.info(f"Logs limpiados: {cleaned} archivos")
+ return cleaned
+
+
+def show_stats():
+ """Muestra estadísticas de uso de disco"""
+ logger.info("=" * 50)
+ logger.info("ESTADÍSTICAS DE ALMACENAMIENTO")
+ logger.info("=" * 50)
+
+ uploads_mb = get_directory_size(UPLOADS_DIR)
+ processed_mb = get_directory_size(PROCESSED_DIR)
+ logs_mb = get_directory_size(LOGS_DIR)
+
+ logger.info(f"Uploads: {uploads_mb:>10.2f} MB")
+ logger.info(f"Processed: {processed_mb:>10.2f} MB")
+ logger.info(f"Logs: {logs_mb:>10.2f} MB")
+ logger.info(f"Total: {uploads_mb + processed_mb + logs_mb:>10.2f} MB")
+ logger.info("=" * 50)
+
+
+# ==========================================
+# EXPIRACIÓN DE ALMACENAMIENTO EN HUGGING FACE
+# ==========================================
+
+def clean_expired_sync_queue(max_age_days=7, dry_run=False):
+ """
+ Ejecuta la limpieza de archivos expirados en Hugging Face Dataset
+ y la purga mensual de usuarios normales si corresponde.
+ """
+ logger.info("Ejecutando limpieza de archivos expirados en Hugging Face Datasets...")
+ if dry_run:
+ logger.info(" [DRY-RUN] Ejecutaría la tarea de limpieza en Hugging Face.")
+ return 0
+
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+ if current_dir not in sys.path:
+ sys.path.insert(0, current_dir)
+
+ try:
+ from tasks import limpiar_stems_expirados_task
+ res = limpiar_stems_expirados_task()
+ cleaned = res.get("cleaned", 0)
+ logger.info(f"Limpieza en Hugging Face completada. Tareas limpiadas: {cleaned}")
+ return cleaned
+ except Exception as e:
+ logger.error(f"Error ejecutando limpiar_stems_expirados_task desde cleanup.py: {e}")
+ return 0
+
+
+# ==========================================
+# MAIN
+# ==========================================
+
+def main():
+ parser = argparse.ArgumentParser(description="Limpieza automática de Melodix")
+ parser.add_argument("--days", type=int, default=7, help="Días de antigüedad para limpiar (default: 7)")
+ parser.add_argument("--dry-run", action="store_true", help="Solo mostrar qué se eliminaría")
+ parser.add_argument("--stats", action="store_true", help="Mostrar estadísticas de uso")
+
+ args = parser.parse_args()
+
+ logger.info("=" * 50)
+ logger.info("MELODIX - LIMPIEZA AUTOMÁTICA")
+ logger.info("=" * 50)
+ logger.info(f"Fecha: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ logger.info(f"Modo: {'DRY-RUN' if args.dry_run else 'NORMAL'}")
+ logger.info(f"Días de antigüedad: {args.days}")
+ logger.info("=" * 50)
+
+ if args.stats:
+ show_stats()
+
+ logger.info("")
+ cleaned_uploads = clean_uploads(max_age_days=1, dry_run=args.dry_run) # Uploads: 1 día
+ logger.info("")
+ cleaned_processed = clean_processed(max_age_days=args.days, dry_run=args.dry_run) # Procesados: X días
+ logger.info("")
+ cleaned_logs = clean_logs(max_age_days=30, dry_run=args.dry_run) # Logs: 30 días
+ logger.info("")
+ cleaned_sync = clean_expired_sync_queue(max_age_days=args.days, dry_run=args.dry_run) # HF/Expira
+
+ logger.info("")
+ logger.info("=" * 50)
+ logger.info("RESUMEN")
+ logger.info("=" * 50)
+ logger.info(f"Uploads limpiados: {cleaned_uploads}")
+ logger.info(f"Procesados limpiados: {cleaned_processed}")
+ logger.info(f"Logs limpiados: {cleaned_logs}")
+ logger.info(f"HF/Nube limpiados: {cleaned_sync}")
+ logger.info("=" * 50)
+
+ if args.dry_run:
+ logger.info("")
+ logger.info("[DRY-RUN] No se eliminó ningún archivo")
+ logger.info("Ejecuta sin --dry-run para aplicar la limpieza")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/cleanup_b2_versions.py b/cleanup_b2_versions.py
index 358c4d2452484a4277f88530fbdd74017ccf78f1..4b94fd44a38516a7b3012dc5ce78b0122bea994b 100644
--- a/cleanup_b2_versions.py
+++ b/cleanup_b2_versions.py
@@ -1,136 +1,136 @@
-"""
-cleanup_b2_versions.py
-======================
-Script de limpieza para eliminar versiones duplicadas de archivos en Backblaze B2.
-Mantiene únicamente la versión más reciente de cada archivo bajo el prefijo 'stems/'.
-
-Uso:
- cd MelodixAPI
- .\\venv_musical\\Scripts\\python cleanup_b2_versions.py [--dry-run]
-"""
-
-import os
-import sys
-import argparse
-from dotenv import load_dotenv
-
-load_dotenv()
-
-import boto3
-from botocore.config import Config
-
-B2_KEY_ID = os.getenv("B2_KEY_ID")
-B2_APPLICATION_KEY = os.getenv("B2_APPLICATION_KEY")
-B2_ENDPOINT = os.getenv("B2_ENDPOINT")
-B2_BUCKET_NAME = os.getenv("B2_BUCKET_NAME")
-
-def get_client():
- region = "us-east-005"
- try:
- parts = B2_ENDPOINT.replace("https://", "").replace("http://", "").split(".")
- if len(parts) >= 2:
- region = parts[1] if "s3" in parts[0] else parts[0]
- except Exception:
- pass
- return boto3.client(
- 's3',
- endpoint_url=B2_ENDPOINT,
- aws_access_key_id=B2_KEY_ID,
- aws_secret_access_key=B2_APPLICATION_KEY,
- region_name=region,
- config=Config(signature_version='s3', s3={'addressing_style': 'path'})
- )
-
-def cleanup_b2_duplicates(prefix="stems/", dry_run=False):
- client = get_client()
- print(f"{'[DRY RUN] ' if dry_run else ''}Escaneando versiones en B2 bucket '{B2_BUCKET_NAME}' bajo prefijo '{prefix}'...\n")
-
- paginator = client.get_paginator('list_object_versions')
- pages = paginator.paginate(Bucket=B2_BUCKET_NAME, Prefix=prefix)
-
- # Agrupar versiones por key
- versions_by_key = {}
- for page in pages:
- for version in page.get('Versions', []):
- key = version['Key']
- versions_by_key.setdefault(key, []).append(version)
- for marker in page.get('DeleteMarkers', []):
- key = marker['Key']
- versions_by_key.setdefault(key, []).append(marker)
-
- total_files = len(versions_by_key)
- total_deleted = 0
- total_kept = 0
- to_delete = []
-
- for key, versions in versions_by_key.items():
- # Filtrar solo versiones reales (no delete markers) y ordenar por LastModified desc
- real_versions = [v for v in versions if v.get('ETag')] # ETag presente = versión real
- delete_markers = [v for v in versions if not v.get('ETag')]
-
- if not real_versions and not delete_markers:
- total_kept += 1
- continue
-
- if len(real_versions) <= 1 and not delete_markers:
- total_kept += 1
- continue
-
- # Ordenar versiones reales por fecha desc, mantener la más reciente
- real_versions.sort(key=lambda v: v['LastModified'], reverse=True)
- old_versions = real_versions[1:] if real_versions else [] # Las demás son duplicados
-
- if old_versions or delete_markers:
- print(f" {key}: {len(real_versions)} versiones, {len(delete_markers)} delete markers -> Eliminando {len(old_versions) + len(delete_markers)} antiguas")
- for v in old_versions:
- to_delete.append({'Key': key, 'VersionId': v['VersionId']})
- total_deleted += 1
- for m in delete_markers:
- to_delete.append({'Key': key, 'VersionId': m['VersionId']})
- total_deleted += 1
- else:
- total_kept += 1
-
- print(f"\nResumen: {total_files} archivos únicos, {total_deleted} versiones/markers a eliminar, {total_kept} sin duplicados")
-
- if not to_delete:
- print("OK: No hay duplicados que limpiar.")
- return
-
- if dry_run:
- print(f"\n[DRY RUN] Se eliminarian {len(to_delete)} versiones antiguas (sin cambios reales).")
- return
-
- # Eliminar en lotes de 1000
- confirmed = input(f"\nConfirmar eliminacion de {len(to_delete)} versiones antiguas? (s/N): ").strip().lower()
- if confirmed != 's':
- print("Cancelado.")
- return
-
- deleted_count = 0
- for i in range(0, len(to_delete), 1000):
- batch = to_delete[i:i+1000]
- resp = client.delete_objects(
- Bucket=B2_BUCKET_NAME,
- Delete={'Objects': batch, 'Quiet': False}
- )
- deleted_count += len(resp.get('Deleted', []))
- errors = resp.get('Errors', [])
- if errors:
- print(f" WARNING: {len(errors)} errores al eliminar:")
- for e in errors:
- print(f" - {e['Key']} v{e['VersionId']}: {e['Message']}")
-
- print(f"\nOK: Limpieza completada: {deleted_count} versiones antiguas eliminadas de B2.")
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="Limpia versiones duplicadas en B2")
- parser.add_argument("--dry-run", action="store_true", help="Solo muestra qué se eliminaría sin hacer cambios")
- parser.add_argument("--prefix", default="stems/", help="Prefijo de B2 a escanear (default: stems/)")
- args = parser.parse_args()
-
- if not all([B2_KEY_ID, B2_APPLICATION_KEY, B2_ENDPOINT, B2_BUCKET_NAME]):
- print("❌ Faltan variables de entorno B2. Verifica tu .env")
- sys.exit(1)
-
- cleanup_b2_duplicates(prefix=args.prefix, dry_run=args.dry_run)
+"""
+cleanup_b2_versions.py
+======================
+Script de limpieza para eliminar versiones duplicadas de archivos en Backblaze B2.
+Mantiene únicamente la versión más reciente de cada archivo bajo el prefijo 'stems/'.
+
+Uso:
+ cd MelodixAPI
+ .\\venv_musical\\Scripts\\python cleanup_b2_versions.py [--dry-run]
+"""
+
+import os
+import sys
+import argparse
+from dotenv import load_dotenv
+
+load_dotenv()
+
+import boto3
+from botocore.config import Config
+
+B2_KEY_ID = os.getenv("B2_KEY_ID")
+B2_APPLICATION_KEY = os.getenv("B2_APPLICATION_KEY")
+B2_ENDPOINT = os.getenv("B2_ENDPOINT")
+B2_BUCKET_NAME = os.getenv("B2_BUCKET_NAME")
+
+def get_client():
+ region = "us-east-005"
+ try:
+ parts = B2_ENDPOINT.replace("https://", "").replace("http://", "").split(".")
+ if len(parts) >= 2:
+ region = parts[1] if "s3" in parts[0] else parts[0]
+ except Exception:
+ pass
+ return boto3.client(
+ 's3',
+ endpoint_url=B2_ENDPOINT,
+ aws_access_key_id=B2_KEY_ID,
+ aws_secret_access_key=B2_APPLICATION_KEY,
+ region_name=region,
+ config=Config(signature_version='s3', s3={'addressing_style': 'path'})
+ )
+
+def cleanup_b2_duplicates(prefix="stems/", dry_run=False):
+ client = get_client()
+ print(f"{'[DRY RUN] ' if dry_run else ''}Escaneando versiones en B2 bucket '{B2_BUCKET_NAME}' bajo prefijo '{prefix}'...\n")
+
+ paginator = client.get_paginator('list_object_versions')
+ pages = paginator.paginate(Bucket=B2_BUCKET_NAME, Prefix=prefix)
+
+ # Agrupar versiones por key
+ versions_by_key = {}
+ for page in pages:
+ for version in page.get('Versions', []):
+ key = version['Key']
+ versions_by_key.setdefault(key, []).append(version)
+ for marker in page.get('DeleteMarkers', []):
+ key = marker['Key']
+ versions_by_key.setdefault(key, []).append(marker)
+
+ total_files = len(versions_by_key)
+ total_deleted = 0
+ total_kept = 0
+ to_delete = []
+
+ for key, versions in versions_by_key.items():
+ # Filtrar solo versiones reales (no delete markers) y ordenar por LastModified desc
+ real_versions = [v for v in versions if v.get('ETag')] # ETag presente = versión real
+ delete_markers = [v for v in versions if not v.get('ETag')]
+
+ if not real_versions and not delete_markers:
+ total_kept += 1
+ continue
+
+ if len(real_versions) <= 1 and not delete_markers:
+ total_kept += 1
+ continue
+
+ # Ordenar versiones reales por fecha desc, mantener la más reciente
+ real_versions.sort(key=lambda v: v['LastModified'], reverse=True)
+ old_versions = real_versions[1:] if real_versions else [] # Las demás son duplicados
+
+ if old_versions or delete_markers:
+ print(f" {key}: {len(real_versions)} versiones, {len(delete_markers)} delete markers -> Eliminando {len(old_versions) + len(delete_markers)} antiguas")
+ for v in old_versions:
+ to_delete.append({'Key': key, 'VersionId': v['VersionId']})
+ total_deleted += 1
+ for m in delete_markers:
+ to_delete.append({'Key': key, 'VersionId': m['VersionId']})
+ total_deleted += 1
+ else:
+ total_kept += 1
+
+ print(f"\nResumen: {total_files} archivos únicos, {total_deleted} versiones/markers a eliminar, {total_kept} sin duplicados")
+
+ if not to_delete:
+ print("OK: No hay duplicados que limpiar.")
+ return
+
+ if dry_run:
+ print(f"\n[DRY RUN] Se eliminarian {len(to_delete)} versiones antiguas (sin cambios reales).")
+ return
+
+ # Eliminar en lotes de 1000
+ confirmed = input(f"\nConfirmar eliminacion de {len(to_delete)} versiones antiguas? (s/N): ").strip().lower()
+ if confirmed != 's':
+ print("Cancelado.")
+ return
+
+ deleted_count = 0
+ for i in range(0, len(to_delete), 1000):
+ batch = to_delete[i:i+1000]
+ resp = client.delete_objects(
+ Bucket=B2_BUCKET_NAME,
+ Delete={'Objects': batch, 'Quiet': False}
+ )
+ deleted_count += len(resp.get('Deleted', []))
+ errors = resp.get('Errors', [])
+ if errors:
+ print(f" WARNING: {len(errors)} errores al eliminar:")
+ for e in errors:
+ print(f" - {e['Key']} v{e['VersionId']}: {e['Message']}")
+
+ print(f"\nOK: Limpieza completada: {deleted_count} versiones antiguas eliminadas de B2.")
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Limpia versiones duplicadas en B2")
+ parser.add_argument("--dry-run", action="store_true", help="Solo muestra qué se eliminaría sin hacer cambios")
+ parser.add_argument("--prefix", default="stems/", help="Prefijo de B2 a escanear (default: stems/)")
+ args = parser.parse_args()
+
+ if not all([B2_KEY_ID, B2_APPLICATION_KEY, B2_ENDPOINT, B2_BUCKET_NAME]):
+ print("❌ Faltan variables de entorno B2. Verifica tu .env")
+ sys.exit(1)
+
+ cleanup_b2_duplicates(prefix=args.prefix, dry_run=args.dry_run)
diff --git a/clear_landing.py b/clear_landing.py
index ef6f6f9eb23ff13e105798dc77da30ca44909037..0105b1e187734b5bc993ca475126e34c9b1bc82e 100644
--- a/clear_landing.py
+++ b/clear_landing.py
@@ -1,25 +1,25 @@
-import sys
-import os
-
-sys.path.append(r'C:\Users\Shrek Negro\Documents\GitHub\melodix\MelodixAPI')
-
-from app.database import SessionLocal
-from sqlalchemy import text
-
-def clear_landing_data():
- db = SessionLocal()
- try:
- print("Clearing landing features...")
- db.execute(text("DELETE FROM landing_features"))
- print("Clearing landing tutorials...")
- db.execute(text("DELETE FROM landing_tutorials"))
- db.commit()
- print("Done. Seed logic will re-populate them on next request.")
- except Exception as e:
- print(f"Error: {e}")
- db.rollback()
- finally:
- db.close()
-
-if __name__ == '__main__':
- clear_landing_data()
+import sys
+import os
+
+sys.path.append(r'C:\Users\Shrek Negro\Documents\GitHub\melodix\MelodixAPI')
+
+from app.database import SessionLocal
+from sqlalchemy import text
+
+def clear_landing_data():
+ db = SessionLocal()
+ try:
+ print("Clearing landing features...")
+ db.execute(text("DELETE FROM landing_features"))
+ print("Clearing landing tutorials...")
+ db.execute(text("DELETE FROM landing_tutorials"))
+ db.commit()
+ print("Done. Seed logic will re-populate them on next request.")
+ except Exception as e:
+ print(f"Error: {e}")
+ db.rollback()
+ finally:
+ db.close()
+
+if __name__ == '__main__':
+ clear_landing_data()
diff --git a/data/preguntas_avanzadas.json b/data/preguntas_avanzadas.json
index 1305922e6b0f747a766b7be5378791e379ae8c6c..971b35b2ff424dd432f659bd2982747661ec673a 100644
--- a/data/preguntas_avanzadas.json
+++ b/data/preguntas_avanzadas.json
@@ -1,323 +1,323 @@
-[
- {
- "pregunta": "¿Qué rey de Judá fue herido con lepra por intentar ofrecer incienso en el altar del templo?",
- "tipo": "opcion_multiple",
- "opciones": ["Uzías", "Ezequías", "Josías", "Manasés"],
- "respuesta_correcta": "Uzías",
- "dia_numero": 1
- },
- {
- "pregunta": "¿Cuál de las siguientes tribus de Israel no recibió una porción de territorio en el reparto de la tierra prometida, sino ciudades para habitar?",
- "tipo": "opcion_multiple",
- "opciones": ["Leví", "Dan", "Simeón", "Gad"],
- "respuesta_correcta": "Leví",
- "dia_numero": 1
- },
- {
- "pregunta": "¿Quién fue el primer sumo sacerdote de Israel según el libro de Levítico?",
- "tipo": "opcion_multiple",
- "opciones": ["Aarón", "Eleazar", "Moisés", "Finees"],
- "respuesta_correcta": "Aarón",
- "dia_numero": 1
- },
- {
- "pregunta": "En el Apocalipsis, ¿cuál es el color del caballo cuyo jinete tiene por nombre Muerte?",
- "tipo": "opcion_multiple",
- "opciones": ["Amarillo", "Blanco", "Rojo", "Negro"],
- "respuesta_correcta": "Amarillo",
- "dia_numero": 1
- },
- {
- "pregunta": "¿Cuál de estos profetas fue contemporáneo del rey Josías y profetizó sobre el juicio inminente a Judá?",
- "tipo": "opcion_multiple",
- "opciones": ["Sofonías", "Isaías", "Ezequiel", "Jonás"],
- "respuesta_correcta": "Sofonías",
- "dia_numero": 1
- },
- {
- "pregunta": "¿Matusalén, el hombre más longevo registrado en las Escrituras, murió el mismo año en que comenzó el Diluvio Universal?",
- "tipo": "verdadero_falso",
- "opciones": ["Verdadero", "Falso"],
- "respuesta_correcta": "Verdadero",
- "dia_numero": 2
- },
- {
- "pregunta": "¿El rey Salomón fue quien dividió el reino de Israel en dos partes tras su muerte?",
- "tipo": "verdadero_falso",
- "opciones": ["Verdadero", "Falso"],
- "respuesta_correcta": "Falso",
- "dia_numero": 2
- },
- {
- "pregunta": "¿El apóstol Pablo fue liberado de la prisión en Filipos gracias a un terremoto que abrió las puertas de la cárcel?",
- "tipo": "verdadero_falso",
- "opciones": ["Verdadero", "Falso"],
- "respuesta_correcta": "Verdadero",
- "dia_numero": 2
- },
- {
- "pregunta": "¿El libro de Ester es el único libro de la Biblia que no menciona explícitamente la palabra 'Dios' o 'Jehová'?",
- "tipo": "verdadero_falso",
- "opciones": ["Verdadero", "Falso"],
- "respuesta_correcta": "Verdadero",
- "dia_numero": 2
- },
- {
- "pregunta": "¿Los tres amigos de Daniel que fueron arrojados al horno de fuego ardiendo fueron Sadrac, Mesac y Abed-nego?",
- "tipo": "verdadero_falso",
- "opciones": ["Verdadero", "Falso"],
- "respuesta_correcta": "Verdadero",
- "dia_numero": 2
- },
- {
- "pregunta": "¿Qué profeta fue arrebatado al cielo en un torbellino con un carro de fuego?",
- "tipo": "manual",
- "opciones": [],
- "respuesta_correcta": "Elías",
- "dia_numero": 3
- },
- {
- "pregunta": "¿Cómo se llamaba la esposa de Moisés, hija del sacerdote de Madián?",
- "tipo": "manual",
- "opciones": [],
- "respuesta_correcta": "Séfora",
- "dia_numero": 3
- },
- {
- "pregunta": "¿Cuál es el nombre del primer libro de la Biblia?",
- "tipo": "manual",
- "opciones": [],
- "respuesta_correcta": "Génesis",
- "dia_numero": 3
- },
- {
- "pregunta": "¿Qué apóstol negó a Jesús tres veces antes de que cantara el gallo?",
- "tipo": "manual",
- "opciones": [],
- "respuesta_correcta": "Pedro",
- "dia_numero": 3
- },
- {
- "pregunta": "¿Cuál es el nombre de la madre de Juan el Bautista?",
- "tipo": "manual",
- "opciones": [],
- "respuesta_correcta": "Isabel",
- "dia_numero": 3
- },
- {
- "pregunta": "Une a cada personaje bíblico con su correspondiente rol o milagro:",
- "tipo": "unir",
- "opciones": [
- {"left": "Elías", "right": "Hizo descender fuego del cielo"},
- {"left": "Moisés", "right": "Dividió las aguas del Mar Rojo"},
- {"left": "Daniel", "right": "Interpretó el sueño de la estatua"},
- {"left": "Pedro", "right": "Caminó sobre las aguas del mar"}
- ],
- "respuesta_correcta": {
- "Elías": "Hizo descender fuego del cielo",
- "Moisés": "Dividió las aguas del Mar Rojo",
- "Daniel": "Interpretó el sueño de la estatua",
- "Pedro": "Caminó sobre las aguas del mar"
- },
- "dia_numero": 4
- },
- {
- "pregunta": "Une cada plaga de Egipto con su número de orden correspondiente:",
- "tipo": "unir",
- "opciones": [
- {"left": "Primera Plaga", "right": "El agua convertida en sangre"},
- {"left": "Segunda Plaga", "right": "Las ranas cubren la tierra"},
- {"left": "Octava Plaga", "right": "Las langostas devoradoras"},
- {"left": "Décima Plaga", "right": "Muerte de los primogénitos"}
- ],
- "respuesta_correcta": {
- "Primera Plaga": "El agua convertida en sangre",
- "Segunda Plaga": "Las ranas cubren la tierra",
- "Octava Plaga": "Las langostas devoradoras",
- "Décima Plaga": "Muerte de los primogénitos"
- },
- "dia_numero": 4
- },
- {
- "pregunta": "Une el regalo ofrecido por los sabios de Oriente a Jesús con su simbolismo:",
- "tipo": "unir",
- "opciones": [
- {"left": "Oro", "right": "Representa la realeza"},
- {"left": "Incienso", "right": "Representa la divinidad"},
- {"left": "Mirra", "right": "Representa la humanidad y sepultura"}
- ],
- "respuesta_correcta": {
- "Oro": "Representa la realeza",
- "Incienso": "Representa la divinidad",
- "Mirra": "Representa la humanidad y sepultura"
- },
- "dia_numero": 4
- },
- {
- "pregunta": "Ordena cronológicamente los siguientes eventos de la vida de Moisés:",
- "tipo": "ordenar_cronologia",
- "opciones": [
- "Nacimiento y rescate del río Nilo",
- "Huida a la tierra de Madián",
- "Llamado en la zarza ardiendo",
- "Las diez plagas de Egipto",
- "Cruzar el Mar Rojo"
- ],
- "respuesta_correcta": [
- "Nacimiento y rescate del río Nilo",
- "Huida a la tierra de Madián",
- "Llamado en la zarza ardiendo",
- "Las diez plagas de Egipto",
- "Cruzar el Mar Rojo"
- ],
- "dia_numero": 5
- },
- {
- "pregunta": "Ordena cronológicamente los primeros cuatro reyes del reino unido de Israel:",
- "tipo": "ordenar_cronologia",
- "opciones": [
- "Saúl",
- "David",
- "Salomón",
- "Roboam"
- ],
- "respuesta_correcta": [
- "Saúl",
- "David",
- "Salomón",
- "Roboam"
- ],
- "dia_numero": 5
- },
- {
- "pregunta": "Ordena cronológicamente los eventos de la Creación según Génesis 1:",
- "tipo": "ordenar_cronologia",
- "opciones": [
- "Creación de la luz",
- "Separación de las aguas y firmamento",
- "Creación de las plantas y árboles",
- "Creación del sol, luna y estrellas",
- "Creación de los animales marinos y aves",
- "Creación del hombre y animales terrestres"
- ],
- "respuesta_correcta": [
- "Creación de la luz",
- "Separación de las aguas y firmamento",
- "Creación de las plantas y árboles",
- "Creación del sol, luna y estrellas",
- "Creación de los animales marinos y aves",
- "Creación del hombre y animales terrestres"
- ],
- "dia_numero": 5
- },
- {
- "pregunta": "Ordena cronológicamente estos profetas del Antiguo Testamento:",
- "tipo": "ordenar_cronologia",
- "opciones": [
- "Moisés",
- "Samuel",
- "Isaías",
- "Daniel",
- "Malaquías"
- ],
- "respuesta_correcta": [
- "Moisés",
- "Samuel",
- "Isaías",
- "Daniel",
- "Malaquías"
- ],
- "dia_numero": 5
- },
- {
- "pregunta": "Fíate de Jehová de todo tu ____, y no te apoyes en tu propia ____.",
- "tipo": "completar_huecos",
- "opciones": ["corazón", "prudencia", "fuerza", "mente", "sabiduría"],
- "respuesta_correcta": ["corazón", "prudencia"],
- "dia_numero": 1
- },
- {
- "pregunta": "Porque tres son los que dan testimonio en el cielo: el Padre, el ____ y el Espíritu Santo; y estos tres son ____.",
- "tipo": "completar_huecos",
- "opciones": ["Verbo", "uno", "Hijo", "tres", "espíritu", "dioses"],
- "respuesta_correcta": ["Verbo", "uno"],
- "dia_numero": 1
- },
- {
- "pregunta": "Lámpara es a mis ____ tu palabra, y ____ a mi camino.",
- "tipo": "completar_huecos",
- "opciones": ["pies", "lumbrera", "ojos", "guía", "antorcha", "luz"],
- "respuesta_correcta": ["pies", "lumbrera"],
- "dia_numero": 2
- },
- {
- "pregunta": "El principio de la ____ es el temor de Jehová; los insensatos desprecian la sabiduría y la ____.",
- "tipo": "completar_huecos",
- "opciones": ["sabiduría", "disciplina", "inteligencia", "ciencia", "enseñanza"],
- "respuesta_correcta": ["sabiduría", "disciplina"],
- "dia_numero": 2
- },
- {
- "pregunta": "Monte donde fue crucificado Jesús, también conocido como el Lugar de la Calavera.",
- "tipo": "revelar_palabra",
- "opciones": [],
- "respuesta_correcta": "GOLGOTA",
- "dia_numero": 3
- },
- {
- "pregunta": "Nombre en hebreo del ángel del abismo en Apocalipsis 9:11.",
- "tipo": "revelar_palabra",
- "opciones": [],
- "respuesta_correcta": "ABADON",
- "dia_numero": 3
- },
- {
- "pregunta": "Nombre de la isla a la que fue desterrado el apóstol Juan por causa del testimonio de Jesucristo.",
- "tipo": "revelar_palabra",
- "opciones": [],
- "respuesta_correcta": "PATMOS",
- "dia_numero": 3
- },
- {
- "pregunta": "Ciudad donde los discípulos fueron llamados cristianos por primera vez.",
- "tipo": "revelar_palabra",
- "opciones": [],
- "respuesta_correcta": "ANTIOQUIA",
- "dia_numero": 4
- },
- {
- "pregunta": "Nombre del monte donde Moisés murió tras contemplar la tierra prometida.",
- "tipo": "revelar_palabra",
- "opciones": [],
- "respuesta_correcta": "NEBO",
- "dia_numero": 4
- },
- {
- "pregunta": "Ordena las palabras para formar el versículo bíblico:",
- "tipo": "identificar_texto",
- "opciones": [],
- "respuesta_correcta": "Juan 3:16 Porque de tal manera amó Dios al mundo, que ha dado a su Hijo unigénito, para que todo aquel que en él cree, no se pierda, mas tenga vida eterna.",
- "dia_numero": 5
- },
- {
- "pregunta": "Ordena las palabras para formar el versículo bíblico:",
- "tipo": "identificar_texto",
- "opciones": [],
- "respuesta_correcta": "Filipenses 4:13 Todo lo puedo en Cristo que me fortalece.",
- "dia_numero": 5
- },
- {
- "pregunta": "Ordena las palabras para formar el versículo bíblico:",
- "tipo": "identificar_texto",
- "opciones": [],
- "respuesta_correcta": "Salmos 23:1 Jehová es mi pastor nada me faltará.",
- "dia_numero": 5
- },
- {
- "pregunta": "Ordena las palabras para formar el versículo bíblico:",
- "tipo": "identificar_texto",
- "opciones": [],
- "respuesta_correcta": "Romanos 8:28 Y sabemos que a los que aman a Dios, todas las cosas les ayudan a bien.",
- "dia_numero": 5
- }
-]
+[
+ {
+ "pregunta": "¿Qué rey de Judá fue herido con lepra por intentar ofrecer incienso en el altar del templo?",
+ "tipo": "opcion_multiple",
+ "opciones": ["Uzías", "Ezequías", "Josías", "Manasés"],
+ "respuesta_correcta": "Uzías",
+ "dia_numero": 1
+ },
+ {
+ "pregunta": "¿Cuál de las siguientes tribus de Israel no recibió una porción de territorio en el reparto de la tierra prometida, sino ciudades para habitar?",
+ "tipo": "opcion_multiple",
+ "opciones": ["Leví", "Dan", "Simeón", "Gad"],
+ "respuesta_correcta": "Leví",
+ "dia_numero": 1
+ },
+ {
+ "pregunta": "¿Quién fue el primer sumo sacerdote de Israel según el libro de Levítico?",
+ "tipo": "opcion_multiple",
+ "opciones": ["Aarón", "Eleazar", "Moisés", "Finees"],
+ "respuesta_correcta": "Aarón",
+ "dia_numero": 1
+ },
+ {
+ "pregunta": "En el Apocalipsis, ¿cuál es el color del caballo cuyo jinete tiene por nombre Muerte?",
+ "tipo": "opcion_multiple",
+ "opciones": ["Amarillo", "Blanco", "Rojo", "Negro"],
+ "respuesta_correcta": "Amarillo",
+ "dia_numero": 1
+ },
+ {
+ "pregunta": "¿Cuál de estos profetas fue contemporáneo del rey Josías y profetizó sobre el juicio inminente a Judá?",
+ "tipo": "opcion_multiple",
+ "opciones": ["Sofonías", "Isaías", "Ezequiel", "Jonás"],
+ "respuesta_correcta": "Sofonías",
+ "dia_numero": 1
+ },
+ {
+ "pregunta": "¿Matusalén, el hombre más longevo registrado en las Escrituras, murió el mismo año en que comenzó el Diluvio Universal?",
+ "tipo": "verdadero_falso",
+ "opciones": ["Verdadero", "Falso"],
+ "respuesta_correcta": "Verdadero",
+ "dia_numero": 2
+ },
+ {
+ "pregunta": "¿El rey Salomón fue quien dividió el reino de Israel en dos partes tras su muerte?",
+ "tipo": "verdadero_falso",
+ "opciones": ["Verdadero", "Falso"],
+ "respuesta_correcta": "Falso",
+ "dia_numero": 2
+ },
+ {
+ "pregunta": "¿El apóstol Pablo fue liberado de la prisión en Filipos gracias a un terremoto que abrió las puertas de la cárcel?",
+ "tipo": "verdadero_falso",
+ "opciones": ["Verdadero", "Falso"],
+ "respuesta_correcta": "Verdadero",
+ "dia_numero": 2
+ },
+ {
+ "pregunta": "¿El libro de Ester es el único libro de la Biblia que no menciona explícitamente la palabra 'Dios' o 'Jehová'?",
+ "tipo": "verdadero_falso",
+ "opciones": ["Verdadero", "Falso"],
+ "respuesta_correcta": "Verdadero",
+ "dia_numero": 2
+ },
+ {
+ "pregunta": "¿Los tres amigos de Daniel que fueron arrojados al horno de fuego ardiendo fueron Sadrac, Mesac y Abed-nego?",
+ "tipo": "verdadero_falso",
+ "opciones": ["Verdadero", "Falso"],
+ "respuesta_correcta": "Verdadero",
+ "dia_numero": 2
+ },
+ {
+ "pregunta": "¿Qué profeta fue arrebatado al cielo en un torbellino con un carro de fuego?",
+ "tipo": "manual",
+ "opciones": [],
+ "respuesta_correcta": "Elías",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "¿Cómo se llamaba la esposa de Moisés, hija del sacerdote de Madián?",
+ "tipo": "manual",
+ "opciones": [],
+ "respuesta_correcta": "Séfora",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "¿Cuál es el nombre del primer libro de la Biblia?",
+ "tipo": "manual",
+ "opciones": [],
+ "respuesta_correcta": "Génesis",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "¿Qué apóstol negó a Jesús tres veces antes de que cantara el gallo?",
+ "tipo": "manual",
+ "opciones": [],
+ "respuesta_correcta": "Pedro",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "¿Cuál es el nombre de la madre de Juan el Bautista?",
+ "tipo": "manual",
+ "opciones": [],
+ "respuesta_correcta": "Isabel",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "Une a cada personaje bíblico con su correspondiente rol o milagro:",
+ "tipo": "unir",
+ "opciones": [
+ {"left": "Elías", "right": "Hizo descender fuego del cielo"},
+ {"left": "Moisés", "right": "Dividió las aguas del Mar Rojo"},
+ {"left": "Daniel", "right": "Interpretó el sueño de la estatua"},
+ {"left": "Pedro", "right": "Caminó sobre las aguas del mar"}
+ ],
+ "respuesta_correcta": {
+ "Elías": "Hizo descender fuego del cielo",
+ "Moisés": "Dividió las aguas del Mar Rojo",
+ "Daniel": "Interpretó el sueño de la estatua",
+ "Pedro": "Caminó sobre las aguas del mar"
+ },
+ "dia_numero": 4
+ },
+ {
+ "pregunta": "Une cada plaga de Egipto con su número de orden correspondiente:",
+ "tipo": "unir",
+ "opciones": [
+ {"left": "Primera Plaga", "right": "El agua convertida en sangre"},
+ {"left": "Segunda Plaga", "right": "Las ranas cubren la tierra"},
+ {"left": "Octava Plaga", "right": "Las langostas devoradoras"},
+ {"left": "Décima Plaga", "right": "Muerte de los primogénitos"}
+ ],
+ "respuesta_correcta": {
+ "Primera Plaga": "El agua convertida en sangre",
+ "Segunda Plaga": "Las ranas cubren la tierra",
+ "Octava Plaga": "Las langostas devoradoras",
+ "Décima Plaga": "Muerte de los primogénitos"
+ },
+ "dia_numero": 4
+ },
+ {
+ "pregunta": "Une el regalo ofrecido por los sabios de Oriente a Jesús con su simbolismo:",
+ "tipo": "unir",
+ "opciones": [
+ {"left": "Oro", "right": "Representa la realeza"},
+ {"left": "Incienso", "right": "Representa la divinidad"},
+ {"left": "Mirra", "right": "Representa la humanidad y sepultura"}
+ ],
+ "respuesta_correcta": {
+ "Oro": "Representa la realeza",
+ "Incienso": "Representa la divinidad",
+ "Mirra": "Representa la humanidad y sepultura"
+ },
+ "dia_numero": 4
+ },
+ {
+ "pregunta": "Ordena cronológicamente los siguientes eventos de la vida de Moisés:",
+ "tipo": "ordenar_cronologia",
+ "opciones": [
+ "Nacimiento y rescate del río Nilo",
+ "Huida a la tierra de Madián",
+ "Llamado en la zarza ardiendo",
+ "Las diez plagas de Egipto",
+ "Cruzar el Mar Rojo"
+ ],
+ "respuesta_correcta": [
+ "Nacimiento y rescate del río Nilo",
+ "Huida a la tierra de Madián",
+ "Llamado en la zarza ardiendo",
+ "Las diez plagas de Egipto",
+ "Cruzar el Mar Rojo"
+ ],
+ "dia_numero": 5
+ },
+ {
+ "pregunta": "Ordena cronológicamente los primeros cuatro reyes del reino unido de Israel:",
+ "tipo": "ordenar_cronologia",
+ "opciones": [
+ "Saúl",
+ "David",
+ "Salomón",
+ "Roboam"
+ ],
+ "respuesta_correcta": [
+ "Saúl",
+ "David",
+ "Salomón",
+ "Roboam"
+ ],
+ "dia_numero": 5
+ },
+ {
+ "pregunta": "Ordena cronológicamente los eventos de la Creación según Génesis 1:",
+ "tipo": "ordenar_cronologia",
+ "opciones": [
+ "Creación de la luz",
+ "Separación de las aguas y firmamento",
+ "Creación de las plantas y árboles",
+ "Creación del sol, luna y estrellas",
+ "Creación de los animales marinos y aves",
+ "Creación del hombre y animales terrestres"
+ ],
+ "respuesta_correcta": [
+ "Creación de la luz",
+ "Separación de las aguas y firmamento",
+ "Creación de las plantas y árboles",
+ "Creación del sol, luna y estrellas",
+ "Creación de los animales marinos y aves",
+ "Creación del hombre y animales terrestres"
+ ],
+ "dia_numero": 5
+ },
+ {
+ "pregunta": "Ordena cronológicamente estos profetas del Antiguo Testamento:",
+ "tipo": "ordenar_cronologia",
+ "opciones": [
+ "Moisés",
+ "Samuel",
+ "Isaías",
+ "Daniel",
+ "Malaquías"
+ ],
+ "respuesta_correcta": [
+ "Moisés",
+ "Samuel",
+ "Isaías",
+ "Daniel",
+ "Malaquías"
+ ],
+ "dia_numero": 5
+ },
+ {
+ "pregunta": "Fíate de Jehová de todo tu ____, y no te apoyes en tu propia ____.",
+ "tipo": "completar_huecos",
+ "opciones": ["corazón", "prudencia", "fuerza", "mente", "sabiduría"],
+ "respuesta_correcta": ["corazón", "prudencia"],
+ "dia_numero": 1
+ },
+ {
+ "pregunta": "Porque tres son los que dan testimonio en el cielo: el Padre, el ____ y el Espíritu Santo; y estos tres son ____.",
+ "tipo": "completar_huecos",
+ "opciones": ["Verbo", "uno", "Hijo", "tres", "espíritu", "dioses"],
+ "respuesta_correcta": ["Verbo", "uno"],
+ "dia_numero": 1
+ },
+ {
+ "pregunta": "Lámpara es a mis ____ tu palabra, y ____ a mi camino.",
+ "tipo": "completar_huecos",
+ "opciones": ["pies", "lumbrera", "ojos", "guía", "antorcha", "luz"],
+ "respuesta_correcta": ["pies", "lumbrera"],
+ "dia_numero": 2
+ },
+ {
+ "pregunta": "El principio de la ____ es el temor de Jehová; los insensatos desprecian la sabiduría y la ____.",
+ "tipo": "completar_huecos",
+ "opciones": ["sabiduría", "disciplina", "inteligencia", "ciencia", "enseñanza"],
+ "respuesta_correcta": ["sabiduría", "disciplina"],
+ "dia_numero": 2
+ },
+ {
+ "pregunta": "Monte donde fue crucificado Jesús, también conocido como el Lugar de la Calavera.",
+ "tipo": "revelar_palabra",
+ "opciones": [],
+ "respuesta_correcta": "GOLGOTA",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "Nombre en hebreo del ángel del abismo en Apocalipsis 9:11.",
+ "tipo": "revelar_palabra",
+ "opciones": [],
+ "respuesta_correcta": "ABADON",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "Nombre de la isla a la que fue desterrado el apóstol Juan por causa del testimonio de Jesucristo.",
+ "tipo": "revelar_palabra",
+ "opciones": [],
+ "respuesta_correcta": "PATMOS",
+ "dia_numero": 3
+ },
+ {
+ "pregunta": "Ciudad donde los discípulos fueron llamados cristianos por primera vez.",
+ "tipo": "revelar_palabra",
+ "opciones": [],
+ "respuesta_correcta": "ANTIOQUIA",
+ "dia_numero": 4
+ },
+ {
+ "pregunta": "Nombre del monte donde Moisés murió tras contemplar la tierra prometida.",
+ "tipo": "revelar_palabra",
+ "opciones": [],
+ "respuesta_correcta": "NEBO",
+ "dia_numero": 4
+ },
+ {
+ "pregunta": "Ordena las palabras para formar el versículo bíblico:",
+ "tipo": "identificar_texto",
+ "opciones": [],
+ "respuesta_correcta": "Juan 3:16 Porque de tal manera amó Dios al mundo, que ha dado a su Hijo unigénito, para que todo aquel que en él cree, no se pierda, mas tenga vida eterna.",
+ "dia_numero": 5
+ },
+ {
+ "pregunta": "Ordena las palabras para formar el versículo bíblico:",
+ "tipo": "identificar_texto",
+ "opciones": [],
+ "respuesta_correcta": "Filipenses 4:13 Todo lo puedo en Cristo que me fortalece.",
+ "dia_numero": 5
+ },
+ {
+ "pregunta": "Ordena las palabras para formar el versículo bíblico:",
+ "tipo": "identificar_texto",
+ "opciones": [],
+ "respuesta_correcta": "Salmos 23:1 Jehová es mi pastor nada me faltará.",
+ "dia_numero": 5
+ },
+ {
+ "pregunta": "Ordena las palabras para formar el versículo bíblico:",
+ "tipo": "identificar_texto",
+ "opciones": [],
+ "respuesta_correcta": "Romanos 8:28 Y sabemos que a los que aman a Dios, todas las cosas les ayudan a bien.",
+ "dia_numero": 5
+ }
+]
diff --git a/download_bass.py b/download_bass.py
index 2446e3fd3ccf90e5c7d45c09358f1a63b23c0f30..b89c695568ba135cfc99ad24460c3ec06f82aa62 100644
--- a/download_bass.py
+++ b/download_bass.py
@@ -1,42 +1,42 @@
-import urllib.request
-import zipfile
-import io
-import os
-
-urls = {
- "bass24.zip": "https://www.un4seen.com/files/bass24.zip",
- "bass_fx24.zip": "https://www.un4seen.com/files/z/0/bass_fx24.zip",
- "bassmix24.zip": "https://www.un4seen.com/files/bassmix24.zip"
-}
-
-dlls_to_extract = {
- "bass24.zip": "x64/bass.dll",
- "bass_fx24.zip": "x64/bass_fx.dll",
- "bassmix24.zip": "x64/bassmix.dll"
-}
-
-output_dir = r"c:\mfmm\Melodix"
-
-req_headers = {'User-Agent': 'Mozilla/5.0'}
-
-for zip_name, url_path in urls.items():
- print(f"Downloading {zip_name}...")
- req = urllib.request.Request(url_path, headers=req_headers)
- response = urllib.request.urlopen(req)
- z = zipfile.ZipFile(io.BytesIO(response.read()))
-
- target_dll = dlls_to_extract[zip_name]
- print(f"Extracting {target_dll}...")
-
- z.extract(target_dll, path=".")
-
- basename = target_dll.split('/')[-1]
- dest_path = os.path.join(output_dir, basename)
-
- src_path = os.path.normpath(target_dll)
- if os.path.exists(dest_path):
- os.remove(dest_path)
- os.rename(src_path, dest_path)
- print(f"Saved to {dest_path}")
-
-print("All BASS 64-bit DLLs downloaded successfully!")
+import urllib.request
+import zipfile
+import io
+import os
+
+urls = {
+ "bass24.zip": "https://www.un4seen.com/files/bass24.zip",
+ "bass_fx24.zip": "https://www.un4seen.com/files/z/0/bass_fx24.zip",
+ "bassmix24.zip": "https://www.un4seen.com/files/bassmix24.zip"
+}
+
+dlls_to_extract = {
+ "bass24.zip": "x64/bass.dll",
+ "bass_fx24.zip": "x64/bass_fx.dll",
+ "bassmix24.zip": "x64/bassmix.dll"
+}
+
+output_dir = r"c:\mfmm\Melodix"
+
+req_headers = {'User-Agent': 'Mozilla/5.0'}
+
+for zip_name, url_path in urls.items():
+ print(f"Downloading {zip_name}...")
+ req = urllib.request.Request(url_path, headers=req_headers)
+ response = urllib.request.urlopen(req)
+ z = zipfile.ZipFile(io.BytesIO(response.read()))
+
+ target_dll = dlls_to_extract[zip_name]
+ print(f"Extracting {target_dll}...")
+
+ z.extract(target_dll, path=".")
+
+ basename = target_dll.split('/')[-1]
+ dest_path = os.path.join(output_dir, basename)
+
+ src_path = os.path.normpath(target_dll)
+ if os.path.exists(dest_path):
+ os.remove(dest_path)
+ os.rename(src_path, dest_path)
+ print(f"Saved to {dest_path}")
+
+print("All BASS 64-bit DLLs downloaded successfully!")
diff --git a/download_bass_android.py b/download_bass_android.py
index fc9aa62027dc7002c88be582c794c169229b25b6..0bcd75e0da2bf6b922a37d112bf91f267a06e8fe 100644
--- a/download_bass_android.py
+++ b/download_bass_android.py
@@ -1,66 +1,66 @@
-import urllib.request
-import zipfile
-import io
-import os
-
-urls = {
- "bass24-android.zip": "https://www.un4seen.com/files/bass24-android.zip",
- "bass_fx24-android.zip": "https://www.un4seen.com/files/z/0/bass_fx24-android.zip",
- "bassmix24-android.zip": "https://www.un4seen.com/files/bassmix24-android.zip"
-}
-
-# Mapping of file names inside the zip to architecture
-archs = ["arm64-v8a", "armeabi-v7a", "x86_64"]
-output_base = r"c:\mfmm\Melodix\Platforms\Android\lib"
-
-req_headers = {'User-Agent': 'Mozilla/5.0'}
-
-for zip_name, url_path in urls.items():
- print(f"Downloading {zip_name}...")
- try:
- req = urllib.request.Request(url_path, headers=req_headers)
- response = urllib.request.urlopen(req)
- zip_bytes = response.read()
- z = zipfile.ZipFile(io.BytesIO(zip_bytes))
-
- # Determine the .so filename (e.g., libbass.so)
- # BASS FX and MIX might have slightly different names inside the zip root or libs folder
- so_name = "lib" + zip_name.split('24')[0] + ".so"
-
- for arch in archs:
- # Try patterns: libs/