Spaces:
Running
Running
GitHub Action commited on
Commit ·
3cc84f5
1
Parent(s): bb3f929
deploy from github actions
Browse files- app/routers/alabanza.py +26 -2
- app/routers/auth.py +2 -63
- app/routers/migration.py +19 -20
- app/routers/songs.py +20 -629
- app/utils/b2_storage.py +0 -192
- app/utils/hf_storage.py +87 -0
- cleanup.py +14 -53
- tasks.py +86 -92
- tests/test_b2_sync_flow.py +0 -310
- tests/test_p2p_sync_flow.py +0 -222
app/routers/alabanza.py
CHANGED
|
@@ -379,7 +379,9 @@ def expulsar_miembro(grupo_id: int, miembro_id: int, db: Session = Depends(get_d
|
|
| 379 |
# Expulsar
|
| 380 |
target_ug.estado = "expulsado"
|
| 381 |
|
| 382 |
-
|
|
|
|
|
|
|
| 383 |
kicked_lyrics = db.query(Lyric).filter(Lyric.created_by == miembro_id, Lyric.groups_shared.any(Group.id == grupo_id)).all()
|
| 384 |
for l in kicked_lyrics:
|
| 385 |
group_to_remove = db.query(Group).filter(Group.id == grupo_id).first()
|
|
@@ -395,6 +397,12 @@ def expulsar_miembro(grupo_id: int, miembro_id: int, db: Session = Depends(get_d
|
|
| 395 |
t.groups_shared.remove(group_to_remove)
|
| 396 |
if t.group_id == grupo_id:
|
| 397 |
t.group_id = t.groups_shared[0].id if t.groups_shared else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
|
| 399 |
db.commit()
|
| 400 |
broadcast_to_group(grupo_id, {"type": "group_updated", "grupo_id": grupo_id}, db)
|
|
@@ -426,6 +434,12 @@ def salir_del_grupo(grupo_id: int, db: Session = Depends(get_db), current_user:
|
|
| 426 |
t.groups_shared.remove(group_to_remove)
|
| 427 |
if t.group_id == grupo_id:
|
| 428 |
t.group_id = t.groups_shared[0].id if t.groups_shared else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
db.commit()
|
| 431 |
broadcast_to_group(grupo_id, {"type": "group_updated", "grupo_id": grupo_id}, db)
|
|
@@ -447,9 +461,19 @@ def eliminar_grupo(grupo_id: int, db: Session = Depends(get_db), current_user: U
|
|
| 447 |
|
| 448 |
tasks_affected = db.query(Task).filter(Task.groups_shared.any(Group.id == grupo_id)).all()
|
| 449 |
for t in tasks_affected:
|
|
|
|
|
|
|
|
|
|
| 450 |
if t.group_id == grupo_id:
|
| 451 |
-
other_groups = [g.id for g in t.groups_shared
|
| 452 |
t.group_id = other_groups[0] if other_groups else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
|
| 454 |
# Eliminar invitaciones del grupo
|
| 455 |
db.query(Invitation).filter(Invitation.group_id == grupo_id).delete()
|
|
|
|
| 379 |
# Expulsar
|
| 380 |
target_ug.estado = "expulsado"
|
| 381 |
|
| 382 |
+
miembro_user = db.query(User).filter(User.id == miembro_id).first()
|
| 383 |
+
is_premium = miembro_user.is_premium if miembro_user else False
|
| 384 |
+
|
| 385 |
kicked_lyrics = db.query(Lyric).filter(Lyric.created_by == miembro_id, Lyric.groups_shared.any(Group.id == grupo_id)).all()
|
| 386 |
for l in kicked_lyrics:
|
| 387 |
group_to_remove = db.query(Group).filter(Group.id == grupo_id).first()
|
|
|
|
| 397 |
t.groups_shared.remove(group_to_remove)
|
| 398 |
if t.group_id == grupo_id:
|
| 399 |
t.group_id = t.groups_shared[0].id if t.groups_shared else None
|
| 400 |
+
# Si no quedan grupos compartidos y es usuario normal, vuelve a expirar
|
| 401 |
+
if not t.groups_shared:
|
| 402 |
+
if is_premium:
|
| 403 |
+
t.cloud_expires_at = None
|
| 404 |
+
else:
|
| 405 |
+
t.cloud_expires_at = datetime.utcnow() + timedelta(days=30)
|
| 406 |
|
| 407 |
db.commit()
|
| 408 |
broadcast_to_group(grupo_id, {"type": "group_updated", "grupo_id": grupo_id}, db)
|
|
|
|
| 434 |
t.groups_shared.remove(group_to_remove)
|
| 435 |
if t.group_id == grupo_id:
|
| 436 |
t.group_id = t.groups_shared[0].id if t.groups_shared else None
|
| 437 |
+
# Si no quedan grupos compartidos y es usuario normal, vuelve a expirar
|
| 438 |
+
if not t.groups_shared:
|
| 439 |
+
if current_user.is_premium:
|
| 440 |
+
t.cloud_expires_at = None
|
| 441 |
+
else:
|
| 442 |
+
t.cloud_expires_at = datetime.utcnow() + timedelta(days=30)
|
| 443 |
|
| 444 |
db.commit()
|
| 445 |
broadcast_to_group(grupo_id, {"type": "group_updated", "grupo_id": grupo_id}, db)
|
|
|
|
| 461 |
|
| 462 |
tasks_affected = db.query(Task).filter(Task.groups_shared.any(Group.id == grupo_id)).all()
|
| 463 |
for t in tasks_affected:
|
| 464 |
+
group_to_remove = db.query(Group).filter(Group.id == grupo_id).first()
|
| 465 |
+
if group_to_remove in t.groups_shared:
|
| 466 |
+
t.groups_shared.remove(group_to_remove)
|
| 467 |
if t.group_id == grupo_id:
|
| 468 |
+
other_groups = [g.id for g in t.groups_shared]
|
| 469 |
t.group_id = other_groups[0] if other_groups else None
|
| 470 |
+
# Si no pertenecen a ningún grupo y son de usuario normal, configurar expiración
|
| 471 |
+
if not t.groups_shared:
|
| 472 |
+
owner = db.query(User).filter(User.id == t.user_id).first()
|
| 473 |
+
if owner and not owner.is_premium:
|
| 474 |
+
t.cloud_expires_at = datetime.utcnow() + timedelta(days=30)
|
| 475 |
+
else:
|
| 476 |
+
t.cloud_expires_at = None
|
| 477 |
|
| 478 |
# Eliminar invitaciones del grupo
|
| 479 |
db.query(Invitation).filter(Invitation.group_id == grupo_id).delete()
|
app/routers/auth.py
CHANGED
|
@@ -30,70 +30,9 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
|
| 30 |
|
| 31 |
def _enqueue_cloud_stems_for_new_device(db: Session, user, new_device_id: str):
|
| 32 |
"""
|
| 33 |
-
|
| 34 |
-
que tienen archivos vigentes en la nube (telegram_file_ids + cloud_expires_at futuro)
|
| 35 |
-
y crea/actualiza entradas B2SyncQueue con el nuevo device_id como pending_recipient.
|
| 36 |
-
Así el PeerSyncService detecta 'pending_my_download' en el primer ciclo y descarga
|
| 37 |
-
automáticamente sin pedir confirmación al usuario.
|
| 38 |
"""
|
| 39 |
-
|
| 40 |
-
from app.models.process import Task
|
| 41 |
-
from app.models.sync import B2SyncQueue
|
| 42 |
-
from datetime import datetime
|
| 43 |
-
import json as _json
|
| 44 |
-
|
| 45 |
-
now = datetime.utcnow()
|
| 46 |
-
tasks_with_cloud = db.query(Task).filter(
|
| 47 |
-
Task.user_id == user.id,
|
| 48 |
-
Task.telegram_file_ids.isnot(None),
|
| 49 |
-
).all()
|
| 50 |
-
|
| 51 |
-
enqueued = 0
|
| 52 |
-
for task in tasks_with_cloud:
|
| 53 |
-
# Verificar que los archivos no han expirado
|
| 54 |
-
if task.cloud_expires_at and task.cloud_expires_at < now:
|
| 55 |
-
continue
|
| 56 |
-
if not task.telegram_file_ids:
|
| 57 |
-
continue
|
| 58 |
-
|
| 59 |
-
# Obtener o crear entrada en B2SyncQueue
|
| 60 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task.task_id).first()
|
| 61 |
-
|
| 62 |
-
if not sync_entry:
|
| 63 |
-
# Construir file_url desde telegram_file_ids
|
| 64 |
-
sync_entry = B2SyncQueue(
|
| 65 |
-
id=task.task_id,
|
| 66 |
-
file_key=f"stems/{task.task_id}",
|
| 67 |
-
file_url=task.telegram_file_ids,
|
| 68 |
-
sync_type="device",
|
| 69 |
-
target_id=str(user.id),
|
| 70 |
-
pending_recipients=[new_device_id],
|
| 71 |
-
is_uploaded=True,
|
| 72 |
-
missing_stems=None
|
| 73 |
-
)
|
| 74 |
-
db.add(sync_entry)
|
| 75 |
-
else:
|
| 76 |
-
# Añadir el nuevo device_id a pending_recipients si no está ya
|
| 77 |
-
pending = sync_entry.pending_recipients
|
| 78 |
-
if isinstance(pending, str):
|
| 79 |
-
pending = _json.loads(pending)
|
| 80 |
-
pending = list(pending) if pending else []
|
| 81 |
-
if new_device_id not in pending:
|
| 82 |
-
pending.append(new_device_id)
|
| 83 |
-
sync_entry.pending_recipients = pending
|
| 84 |
-
# Asegurarse que is_uploaded sea True (hay archivos en la nube)
|
| 85 |
-
sync_entry.is_uploaded = True
|
| 86 |
-
# Actualizar file_url con las URLs actuales si estaba vacío
|
| 87 |
-
if not sync_entry.file_url or sync_entry.file_url == "{}":
|
| 88 |
-
sync_entry.file_url = task.telegram_file_ids
|
| 89 |
-
|
| 90 |
-
enqueued += 1
|
| 91 |
-
|
| 92 |
-
if enqueued > 0:
|
| 93 |
-
db.commit()
|
| 94 |
-
logger.info(f"[auth] Encoladas {enqueued} pistas en B2SyncQueue para nuevo dispositivo {new_device_id} del usuario {user.username}")
|
| 95 |
-
except Exception as e:
|
| 96 |
-
logger.warning(f"[auth] Error encolando pistas para nuevo dispositivo {new_device_id}: {e}")
|
| 97 |
|
| 98 |
# ==========================================
|
| 99 |
# CONFIGURACIÓN DE SEGURIDAD
|
|
|
|
| 30 |
|
| 31 |
def _enqueue_cloud_stems_for_new_device(db: Session, user, new_device_id: str):
|
| 32 |
"""
|
| 33 |
+
No-op: La sincronización P2P por cola de dispositivos ha sido eliminada.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"""
|
| 35 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# ==========================================
|
| 38 |
# CONFIGURACIÓN DE SEGURIDAD
|
app/routers/migration.py
CHANGED
|
@@ -12,10 +12,10 @@ from app.database import get_db
|
|
| 12 |
from app.models.user import User
|
| 13 |
from app.models.sync import MigrationBackup
|
| 14 |
from app.routers.auth import get_current_user
|
| 15 |
-
from app.utils.
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
)
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
|
@@ -39,7 +39,7 @@ def cleanup_expired_backups(db: Session, user_id: int):
|
|
| 39 |
for eb in expired:
|
| 40 |
try:
|
| 41 |
logger.info(f"Limpiando respaldo expirado {eb.id} para usuario {user_id}")
|
| 42 |
-
|
| 43 |
eb.status = "expired"
|
| 44 |
except Exception as e:
|
| 45 |
logger.error(f"Error limpiando respaldo expirado {eb.id}: {e}")
|
|
@@ -54,7 +54,7 @@ def create_backup(
|
|
| 54 |
current_user: User = Depends(get_current_user)
|
| 55 |
):
|
| 56 |
"""
|
| 57 |
-
Inicia un proceso de respaldo. Elimina cualquier respaldo anterior en
|
| 58 |
"""
|
| 59 |
# 1. Limpieza de expirados
|
| 60 |
cleanup_expired_backups(db, current_user.id)
|
|
@@ -65,10 +65,10 @@ def create_backup(
|
|
| 65 |
MigrationBackup.status.in_(["pending", "completed"])
|
| 66 |
).all()
|
| 67 |
|
| 68 |
-
# Eliminar archivos en
|
| 69 |
if active_backups:
|
| 70 |
logger.info(f"Eliminando {len(active_backups)} respaldos activos previos para usuario {current_user.id}")
|
| 71 |
-
|
| 72 |
for ab in active_backups:
|
| 73 |
db.delete(ab)
|
| 74 |
db.commit()
|
|
@@ -102,7 +102,7 @@ def upload_backup_file(
|
|
| 102 |
current_user: User = Depends(get_current_user)
|
| 103 |
):
|
| 104 |
"""
|
| 105 |
-
Recibe un stem individual en formato .flac o .json y lo sube a la ruta migrations/ de
|
| 106 |
"""
|
| 107 |
# Buscar y validar el respaldo
|
| 108 |
backup = db.query(MigrationBackup).filter(
|
|
@@ -142,9 +142,9 @@ def upload_backup_file(
|
|
| 142 |
with open(temp_file_path, "wb") as buffer:
|
| 143 |
shutil.copyfileobj(file.file, buffer)
|
| 144 |
|
| 145 |
-
# Subir a
|
| 146 |
object_key = f"migrations/{current_user.id}/{task_id}/{stem_name}.{ext}"
|
| 147 |
-
public_url =
|
| 148 |
|
| 149 |
# Registrar el archivo en la BD
|
| 150 |
files_list = list(backup.files_info) if backup.files_info else []
|
|
@@ -159,7 +159,7 @@ def upload_backup_file(
|
|
| 159 |
|
| 160 |
except Exception as e:
|
| 161 |
logger.error(f"Error subiendo archivo de migración {stem_name} para {task_id}: {e}")
|
| 162 |
-
raise HTTPException(status_code=500, detail=f"Error en la subida a
|
| 163 |
finally:
|
| 164 |
if os.path.exists(temp_file_path):
|
| 165 |
os.remove(temp_file_path)
|
|
@@ -225,7 +225,7 @@ def restore_backup(
|
|
| 225 |
current_user: User = Depends(get_current_user)
|
| 226 |
):
|
| 227 |
"""
|
| 228 |
-
Retorna el mapa de AsyncStorage y las URLs
|
| 229 |
"""
|
| 230 |
cleanup_expired_backups(db, current_user.id)
|
| 231 |
|
|
@@ -237,14 +237,13 @@ def restore_backup(
|
|
| 237 |
if not backup:
|
| 238 |
raise HTTPException(status_code=404, detail="No tienes ningún respaldo activo listo para restaurar")
|
| 239 |
|
| 240 |
-
# Generar URLs
|
| 241 |
restore_files = []
|
| 242 |
files_list = backup.files_info or []
|
| 243 |
|
| 244 |
for f in files_list:
|
| 245 |
file_key = f["file_key"]
|
| 246 |
-
|
| 247 |
-
download_url = get_b2_download_url(file_key, expires_in=14400)
|
| 248 |
restore_files.append({
|
| 249 |
"task_id": f["task_id"],
|
| 250 |
"stem_name": f["stem_name"],
|
|
@@ -263,7 +262,7 @@ def complete_restore(
|
|
| 263 |
current_user: User = Depends(get_current_user)
|
| 264 |
):
|
| 265 |
"""
|
| 266 |
-
Confirma que el nuevo dispositivo ha terminado de descargar los archivos y elimina los datos de
|
| 267 |
"""
|
| 268 |
backup = db.query(MigrationBackup).filter(
|
| 269 |
MigrationBackup.user_id == current_user.id,
|
|
@@ -271,9 +270,9 @@ def complete_restore(
|
|
| 271 |
).order_by(MigrationBackup.created_at.desc()).first()
|
| 272 |
|
| 273 |
if backup:
|
| 274 |
-
# Eliminar archivos físicamente de
|
| 275 |
-
logger.info(f"Limpiando carpeta de
|
| 276 |
-
|
| 277 |
backup.status = "restored"
|
| 278 |
db.commit()
|
| 279 |
|
|
|
|
| 12 |
from app.models.user import User
|
| 13 |
from app.models.sync import MigrationBackup
|
| 14 |
from app.routers.auth import get_current_user
|
| 15 |
+
from app.utils.hf_storage import (
|
| 16 |
+
upload_to_hf,
|
| 17 |
+
delete_folder_from_hf,
|
| 18 |
+
get_hf_download_url
|
| 19 |
)
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
|
|
|
| 39 |
for eb in expired:
|
| 40 |
try:
|
| 41 |
logger.info(f"Limpiando respaldo expirado {eb.id} para usuario {user_id}")
|
| 42 |
+
delete_folder_from_hf(f"migrations/{user_id}")
|
| 43 |
eb.status = "expired"
|
| 44 |
except Exception as e:
|
| 45 |
logger.error(f"Error limpiando respaldo expirado {eb.id}: {e}")
|
|
|
|
| 54 |
current_user: User = Depends(get_current_user)
|
| 55 |
):
|
| 56 |
"""
|
| 57 |
+
Inicia un proceso de respaldo. Elimina cualquier respaldo anterior en Hugging Face y BD.
|
| 58 |
"""
|
| 59 |
# 1. Limpieza de expirados
|
| 60 |
cleanup_expired_backups(db, current_user.id)
|
|
|
|
| 65 |
MigrationBackup.status.in_(["pending", "completed"])
|
| 66 |
).all()
|
| 67 |
|
| 68 |
+
# Eliminar archivos en Hugging Face y registros en BD de respaldos anteriores
|
| 69 |
if active_backups:
|
| 70 |
logger.info(f"Eliminando {len(active_backups)} respaldos activos previos para usuario {current_user.id}")
|
| 71 |
+
delete_folder_from_hf(f"migrations/{current_user.id}")
|
| 72 |
for ab in active_backups:
|
| 73 |
db.delete(ab)
|
| 74 |
db.commit()
|
|
|
|
| 102 |
current_user: User = Depends(get_current_user)
|
| 103 |
):
|
| 104 |
"""
|
| 105 |
+
Recibe un stem individual en formato .flac o .json y lo sube a la ruta migrations/ de Hugging Face.
|
| 106 |
"""
|
| 107 |
# Buscar y validar el respaldo
|
| 108 |
backup = db.query(MigrationBackup).filter(
|
|
|
|
| 142 |
with open(temp_file_path, "wb") as buffer:
|
| 143 |
shutil.copyfileobj(file.file, buffer)
|
| 144 |
|
| 145 |
+
# Subir a Hugging Face Dataset
|
| 146 |
object_key = f"migrations/{current_user.id}/{task_id}/{stem_name}.{ext}"
|
| 147 |
+
public_url = upload_to_hf(temp_file_path, object_key)
|
| 148 |
|
| 149 |
# Registrar el archivo en la BD
|
| 150 |
files_list = list(backup.files_info) if backup.files_info else []
|
|
|
|
| 159 |
|
| 160 |
except Exception as e:
|
| 161 |
logger.error(f"Error subiendo archivo de migración {stem_name} para {task_id}: {e}")
|
| 162 |
+
raise HTTPException(status_code=500, detail=f"Error en la subida a Hugging Face: {str(e)}")
|
| 163 |
finally:
|
| 164 |
if os.path.exists(temp_file_path):
|
| 165 |
os.remove(temp_file_path)
|
|
|
|
| 225 |
current_user: User = Depends(get_current_user)
|
| 226 |
):
|
| 227 |
"""
|
| 228 |
+
Retorna el mapa de AsyncStorage y las URLs de descarga de Hugging Face para todos los stems respaldados.
|
| 229 |
"""
|
| 230 |
cleanup_expired_backups(db, current_user.id)
|
| 231 |
|
|
|
|
| 237 |
if not backup:
|
| 238 |
raise HTTPException(status_code=404, detail="No tienes ningún respaldo activo listo para restaurar")
|
| 239 |
|
| 240 |
+
# Generar URLs de descarga de Hugging Face
|
| 241 |
restore_files = []
|
| 242 |
files_list = backup.files_info or []
|
| 243 |
|
| 244 |
for f in files_list:
|
| 245 |
file_key = f["file_key"]
|
| 246 |
+
download_url = get_hf_download_url(file_key)
|
|
|
|
| 247 |
restore_files.append({
|
| 248 |
"task_id": f["task_id"],
|
| 249 |
"stem_name": f["stem_name"],
|
|
|
|
| 262 |
current_user: User = Depends(get_current_user)
|
| 263 |
):
|
| 264 |
"""
|
| 265 |
+
Confirma que el nuevo dispositivo ha terminado de descargar los archivos y elimina los datos de Hugging Face.
|
| 266 |
"""
|
| 267 |
backup = db.query(MigrationBackup).filter(
|
| 268 |
MigrationBackup.user_id == current_user.id,
|
|
|
|
| 270 |
).order_by(MigrationBackup.created_at.desc()).first()
|
| 271 |
|
| 272 |
if backup:
|
| 273 |
+
# Eliminar archivos físicamente de Hugging Face
|
| 274 |
+
logger.info(f"Limpiando carpeta de HF tras restauración exitosa de {backup.id}")
|
| 275 |
+
delete_folder_from_hf(f"migrations/{current_user.id}")
|
| 276 |
backup.status = "restored"
|
| 277 |
db.commit()
|
| 278 |
|
app/routers/songs.py
CHANGED
|
@@ -657,67 +657,11 @@ async def historial_canciones(
|
|
| 657 |
else:
|
| 658 |
total = db.query(Task).filter(Task.user_id == current_user.id).count()
|
| 659 |
|
| 660 |
-
from app.models.sync import B2SyncQueue
|
| 661 |
-
from datetime import datetime, timedelta
|
| 662 |
-
|
| 663 |
-
task_ids = [t.task_id for t in tasks]
|
| 664 |
-
sync_entries = {s.id: s for s in db.query(B2SyncQueue).filter(B2SyncQueue.id.in_(task_ids)).all()}
|
| 665 |
-
|
| 666 |
formatted_tasks = []
|
| 667 |
-
now = datetime.utcnow()
|
| 668 |
|
| 669 |
for t in tasks:
|
| 670 |
-
sync_entry = sync_entries.get(t.task_id)
|
| 671 |
sync_status = "available"
|
| 672 |
missing_stems_for_me = []
|
| 673 |
-
|
| 674 |
-
if sync_entry:
|
| 675 |
-
is_recipient = False
|
| 676 |
-
recipient_key = None
|
| 677 |
-
|
| 678 |
-
user_devices = [d.device_id for d in current_user.devices]
|
| 679 |
-
|
| 680 |
-
pending_recipients = sync_entry.pending_recipients
|
| 681 |
-
if isinstance(pending_recipients, str):
|
| 682 |
-
pending_recipients = _json.loads(pending_recipients)
|
| 683 |
-
|
| 684 |
-
for dev in user_devices:
|
| 685 |
-
if dev in (pending_recipients or []):
|
| 686 |
-
is_recipient = True
|
| 687 |
-
recipient_key = dev
|
| 688 |
-
break
|
| 689 |
-
|
| 690 |
-
if is_recipient:
|
| 691 |
-
if not sync_entry.is_uploaded:
|
| 692 |
-
if sync_entry.uploading_by:
|
| 693 |
-
if sync_entry.uploading_claimed_at and now - sync_entry.uploading_claimed_at > timedelta(minutes=5):
|
| 694 |
-
sync_status = "pending_upload"
|
| 695 |
-
else:
|
| 696 |
-
sync_status = "uploading"
|
| 697 |
-
else:
|
| 698 |
-
sync_status = "pending_upload"
|
| 699 |
-
else:
|
| 700 |
-
sync_status = "pending_my_download"
|
| 701 |
-
|
| 702 |
-
missing_stems = sync_entry.missing_stems
|
| 703 |
-
if isinstance(missing_stems, str):
|
| 704 |
-
missing_stems = _json.loads(missing_stems)
|
| 705 |
-
|
| 706 |
-
if missing_stems and recipient_key in missing_stems:
|
| 707 |
-
missing_stems_for_me = missing_stems[recipient_key]
|
| 708 |
-
else:
|
| 709 |
-
count = t.stems or 4
|
| 710 |
-
missing_stems_for_me = ["vocals", "no_vocals"] if count == 2 else (["vocals", "bass", "drums", "guitar", "piano", "other"] if count == 6 else ["vocals", "bass", "drums", "other"])
|
| 711 |
-
else:
|
| 712 |
-
if not sync_entry.is_uploaded:
|
| 713 |
-
if sync_entry.uploading_by:
|
| 714 |
-
if sync_entry.uploading_claimed_at and now - sync_entry.uploading_claimed_at > timedelta(minutes=5):
|
| 715 |
-
sync_status = "pending_upload"
|
| 716 |
-
else:
|
| 717 |
-
sync_status = "uploading"
|
| 718 |
-
else:
|
| 719 |
-
sync_status = "pending_upload"
|
| 720 |
-
|
| 721 |
# Detectar si hay voces corales generadas en la nube (al menos 2da_voz presente)
|
| 722 |
has_harmony = False
|
| 723 |
if t.telegram_file_ids:
|
|
@@ -966,11 +910,6 @@ async def get_stems(
|
|
| 966 |
if task_record.cloud_expires_at and task_record.cloud_expires_at < datetime.utcnow():
|
| 967 |
task_record.telegram_file_ids = None
|
| 968 |
task_record.cloud_expires_at = None
|
| 969 |
-
from app.models.sync import B2SyncQueue
|
| 970 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 971 |
-
if sync_entry:
|
| 972 |
-
sync_entry.is_uploaded = False
|
| 973 |
-
sync_entry.file_url = "{}"
|
| 974 |
db.commit()
|
| 975 |
raise HTTPException(
|
| 976 |
status_code=404,
|
|
@@ -980,17 +919,17 @@ async def get_stems(
|
|
| 980 |
if task_record.telegram_file_ids:
|
| 981 |
try:
|
| 982 |
stem_urls = _json.loads(task_record.telegram_file_ids)
|
| 983 |
-
from app.utils.
|
| 984 |
|
| 985 |
presigned_urls = {}
|
| 986 |
for stem_name, file_url in stem_urls.items():
|
| 987 |
if "stems/" in file_url:
|
| 988 |
# Extraer el key del objeto de forma genérica (ej. stems/task_id/stem.m4a)
|
| 989 |
-
# No depende del dominio
|
| 990 |
parts = file_url.split("stems/")
|
| 991 |
if len(parts) > 1:
|
| 992 |
object_key = "stems/" + parts[-1]
|
| 993 |
-
presigned_urls[stem_name] =
|
| 994 |
else:
|
| 995 |
presigned_urls[stem_name] = file_url
|
| 996 |
else:
|
|
@@ -1017,7 +956,7 @@ async def reset_cloud_stems(
|
|
| 1017 |
current_user: User = Depends(get_current_user)
|
| 1018 |
):
|
| 1019 |
"""
|
| 1020 |
-
Limpia los metadatos de stems en la nube para forzar una resincronización
|
| 1021 |
"""
|
| 1022 |
user_group_ids = [ug.group_id for ug in current_user.groups if ug.estado == 'activo']
|
| 1023 |
task_record = db.query(Task).filter(Task.task_id == task_id).filter(
|
|
@@ -1030,15 +969,9 @@ async def reset_cloud_stems(
|
|
| 1030 |
# Limpiar los metadatos de la nube
|
| 1031 |
task_record.telegram_file_ids = None
|
| 1032 |
task_record.cloud_expires_at = None
|
| 1033 |
-
|
| 1034 |
-
from app.models.sync import B2SyncQueue
|
| 1035 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 1036 |
-
if sync_entry:
|
| 1037 |
-
sync_entry.is_uploaded = False
|
| 1038 |
-
sync_entry.file_url = "{}"
|
| 1039 |
|
| 1040 |
db.commit()
|
| 1041 |
-
logger.info(f"[{task_id}] Se han reseteado los stems en la nube
|
| 1042 |
return {"success": True, "message": "Stems en la nube reseteados correctamente."}
|
| 1043 |
|
| 1044 |
|
|
@@ -1046,189 +979,18 @@ async def reset_cloud_stems(
|
|
| 1046 |
@router.post("/confirm-download/{task_id}")
|
| 1047 |
async def confirmar_descarga(
|
| 1048 |
task_id: str,
|
| 1049 |
-
device_id: str = None,
|
| 1050 |
download_type: str = "stems",
|
| 1051 |
db: Session = Depends(get_db),
|
| 1052 |
current_user: User = Depends(get_current_user)
|
| 1053 |
):
|
| 1054 |
"""
|
| 1055 |
El cliente llama a este endpoint al confirmar que descargó todos los stems.
|
| 1056 |
-
|
| 1057 |
-
Al quedar en 0 destinatarios pendientes, se eliminan los archivos de Backblaze B2.
|
| 1058 |
-
Si no existe en B2SyncQueue (Estándar), se dispara la tarea para borrar los stems locales.
|
| 1059 |
"""
|
| 1060 |
-
|
| 1061 |
-
from app.models.sync import B2SyncQueue
|
| 1062 |
-
from app.utils.b2_storage import delete_from_b2
|
| 1063 |
-
from datetime import datetime, timedelta
|
| 1064 |
-
|
| 1065 |
-
# 1. Buscar en B2SyncQueue (Premium o Grupo)
|
| 1066 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 1067 |
|
| 1068 |
-
|
| 1069 |
-
logger.info(f"[{task_id}] Confirmación de descarga Premium/Grupo/Estándar recibida. sync_type: {sync_entry.sync_type}")
|
| 1070 |
-
|
| 1071 |
-
# Si es usuario estándar (is_premium == False), purgar de inmediato de B2 para liberar espacio
|
| 1072 |
-
if sync_entry.sync_type == "device" and not current_user.is_premium:
|
| 1073 |
-
# Comprobar si tiene voces pero no se han generado las armonías aún
|
| 1074 |
-
has_vocals = False
|
| 1075 |
-
has_harmonies = False
|
| 1076 |
-
try:
|
| 1077 |
-
urls_dict = _json.loads(sync_entry.file_url) if sync_entry.file_url else {}
|
| 1078 |
-
has_vocals = "vocals" in urls_dict
|
| 1079 |
-
has_harmonies = "2da_voz" in urls_dict
|
| 1080 |
-
except Exception:
|
| 1081 |
-
pass
|
| 1082 |
-
|
| 1083 |
-
if has_vocals and not has_harmonies:
|
| 1084 |
-
logger.info(f"[{task_id}] Pospone purga de B2 para usuario estándar porque aún no se han generado las armonías corales.")
|
| 1085 |
-
elif has_harmonies and download_type != "harmonies":
|
| 1086 |
-
logger.info(f"[{task_id}] Pospone purga de B2 para usuario estándar porque la canción tiene armonías corales y la descarga confirmada es '{download_type}'.")
|
| 1087 |
-
else:
|
| 1088 |
-
logger.info(f"[{task_id}] Usuario estándar detectado (tipo: {download_type}). Eliminando de B2 de inmediato...")
|
| 1089 |
-
try:
|
| 1090 |
-
urls_dict = _json.loads(sync_entry.file_url)
|
| 1091 |
-
for stem_name, stem_url in urls_dict.items():
|
| 1092 |
-
if stem_name.endswith("_pitch"):
|
| 1093 |
-
key = f"stems/{task_id}/{stem_name}.json"
|
| 1094 |
-
else:
|
| 1095 |
-
ext = "m4a" if ".m4a" in stem_url.lower() else "flac"
|
| 1096 |
-
key = f"stems/{task_id}/{stem_name}.{ext}"
|
| 1097 |
-
delete_from_b2(key)
|
| 1098 |
-
logger.info(f"[{task_id}] Stems de usuario estándar eliminados de B2.")
|
| 1099 |
-
except Exception as e:
|
| 1100 |
-
logger.error(f"[{task_id}] Error eliminando stems de B2 para usuario estándar: {e}")
|
| 1101 |
-
|
| 1102 |
-
db.delete(sync_entry)
|
| 1103 |
-
# También limpiar metadatos de la nube en Task de inmediato
|
| 1104 |
-
task_record_std = db.query(Task).filter(Task.task_id == task_id).first()
|
| 1105 |
-
if task_record_std:
|
| 1106 |
-
task_record_std.telegram_file_ids = None
|
| 1107 |
-
task_record_std.cloud_expires_at = None
|
| 1108 |
-
db.commit()
|
| 1109 |
-
|
| 1110 |
-
# Por si acaso existieran archivos locales, gatillar la tarea de eliminación local
|
| 1111 |
-
from tasks import eliminar_archivos_locales_task
|
| 1112 |
-
eliminar_archivos_locales_task.delay(task_id)
|
| 1113 |
-
else:
|
| 1114 |
-
if sync_entry:
|
| 1115 |
-
import json as _json
|
| 1116 |
-
to_remove = device_id or str(current_user.id)
|
| 1117 |
-
|
| 1118 |
-
# Comprobar si la canción tiene armonías
|
| 1119 |
-
has_harmonies = False
|
| 1120 |
-
try:
|
| 1121 |
-
urls_dict = _json.loads(sync_entry.file_url) if sync_entry.file_url else {}
|
| 1122 |
-
has_harmonies = "2da_voz" in urls_dict
|
| 1123 |
-
except Exception:
|
| 1124 |
-
pass
|
| 1125 |
-
|
| 1126 |
-
# Si tiene armonías y la descarga confirmada es de los stems (no armonías), posponer la remoción y limpieza
|
| 1127 |
-
if has_harmonies and download_type != "harmonies":
|
| 1128 |
-
logger.info(f"[{task_id}] Pospone remoción/purga de B2 para Premium/Grupo porque la canción tiene armonías corales y la descarga confirmada es '{download_type}'.")
|
| 1129 |
-
else:
|
| 1130 |
-
pending = sync_entry.pending_recipients
|
| 1131 |
-
if isinstance(pending, str):
|
| 1132 |
-
pending = _json.loads(pending)
|
| 1133 |
-
pending = list(pending) if pending else []
|
| 1134 |
-
|
| 1135 |
-
if to_remove in pending:
|
| 1136 |
-
pending.remove(to_remove)
|
| 1137 |
-
else:
|
| 1138 |
-
# Fallback: si to_remove no está pero coincide con el user_id, intentar buscar si alguno de los dispositivos de este usuario está en pending
|
| 1139 |
-
user_devices = [d.device_id for d in current_user.devices]
|
| 1140 |
-
for dev in user_devices:
|
| 1141 |
-
if dev in pending:
|
| 1142 |
-
pending.remove(dev)
|
| 1143 |
-
logger.info(f"[{task_id}] Removido dispositivo del usuario por fallback: {dev}")
|
| 1144 |
-
break
|
| 1145 |
-
sync_entry.pending_recipients = pending
|
| 1146 |
-
|
| 1147 |
-
# También remover de missing_stems
|
| 1148 |
-
missing = sync_entry.missing_stems
|
| 1149 |
-
if isinstance(missing, str):
|
| 1150 |
-
missing = _json.loads(missing)
|
| 1151 |
-
missing = dict(missing) if missing else {}
|
| 1152 |
-
|
| 1153 |
-
if to_remove in missing:
|
| 1154 |
-
del missing[to_remove]
|
| 1155 |
-
else:
|
| 1156 |
-
# Fallback para missing_stems
|
| 1157 |
-
user_devices = [d.device_id for d in current_user.devices]
|
| 1158 |
-
for dev in user_devices:
|
| 1159 |
-
if dev in missing:
|
| 1160 |
-
del missing[dev]
|
| 1161 |
-
logger.info(f"[{task_id}] Removido dispositivo de missing_stems por fallback: {dev}")
|
| 1162 |
-
break
|
| 1163 |
-
sync_entry.missing_stems = missing
|
| 1164 |
-
|
| 1165 |
-
db.commit()
|
| 1166 |
-
logger.info(f"[{task_id}] Removido {to_remove} de destinatarios. Restan: {pending}")
|
| 1167 |
-
|
| 1168 |
-
# --- NUEVA LÓGICA DE DETECCION DE DISPOSITIVOS ACTIVOS ---
|
| 1169 |
-
from app.models.user import Device
|
| 1170 |
-
|
| 1171 |
-
active_threshold = datetime.utcnow() - timedelta(minutes=5)
|
| 1172 |
-
|
| 1173 |
-
if sync_entry.sync_type == "group":
|
| 1174 |
-
from app.models.lyrics import UserGroup
|
| 1175 |
-
active_members = db.query(UserGroup).filter(
|
| 1176 |
-
UserGroup.group_id == int(sync_entry.target_id),
|
| 1177 |
-
UserGroup.estado == "activo"
|
| 1178 |
-
).all()
|
| 1179 |
-
member_ids = [m.user_id for m in active_members]
|
| 1180 |
-
other_active_recipients = [
|
| 1181 |
-
str(d.user_id) for d in db.query(Device).filter(
|
| 1182 |
-
Device.user_id.in_(member_ids),
|
| 1183 |
-
Device.last_active >= active_threshold,
|
| 1184 |
-
Device.user_id != current_user.id
|
| 1185 |
-
).all()
|
| 1186 |
-
]
|
| 1187 |
-
has_other_active_recipients = any(uid in pending for uid in other_active_recipients)
|
| 1188 |
-
else:
|
| 1189 |
-
other_active_devices = [
|
| 1190 |
-
d.device_id for d in current_user.devices
|
| 1191 |
-
if d.last_active and d.last_active >= active_threshold and d.device_id != to_remove
|
| 1192 |
-
]
|
| 1193 |
-
has_other_active_recipients = any(dev in pending for dev in other_active_devices)
|
| 1194 |
-
|
| 1195 |
-
# Si se vacía la lista de pendientes o no queda ningún otro destinatario ACTIVO,
|
| 1196 |
-
# eliminar de Tebi para liberar espacio y marcar is_uploaded = False si quedan inactivos.
|
| 1197 |
-
if not pending or not has_other_active_recipients:
|
| 1198 |
-
try:
|
| 1199 |
-
urls_dict = _json.loads(sync_entry.file_url)
|
| 1200 |
-
for stem_name, stem_url in urls_dict.items():
|
| 1201 |
-
if stem_name.endswith("_pitch"):
|
| 1202 |
-
key = f"stems/{task_id}/{stem_name}.json"
|
| 1203 |
-
else:
|
| 1204 |
-
ext = "m4a" if ".m4a" in stem_url.lower() else "flac"
|
| 1205 |
-
key = f"stems/{task_id}/{stem_name}.{ext}"
|
| 1206 |
-
delete_from_b2(key)
|
| 1207 |
-
logger.info(f"[{task_id}] No quedan destinatarios activos. Archivos eliminados de Tebi. Restantes offline: {pending}")
|
| 1208 |
-
except Exception as e:
|
| 1209 |
-
logger.error(f"[{task_id}] Error eliminando stems de B2: {e}")
|
| 1210 |
-
|
| 1211 |
-
# También limpiar metadatos de la nube en Task de inmediato
|
| 1212 |
-
task_record_prm = db.query(Task).filter(Task.task_id == task_id).first()
|
| 1213 |
-
if task_record_prm:
|
| 1214 |
-
task_record_prm.telegram_file_ids = None
|
| 1215 |
-
task_record_prm.cloud_expires_at = None
|
| 1216 |
-
|
| 1217 |
-
if not pending:
|
| 1218 |
-
db.delete(sync_entry)
|
| 1219 |
-
db.commit()
|
| 1220 |
-
else:
|
| 1221 |
-
sync_entry.is_uploaded = False
|
| 1222 |
-
db.commit()
|
| 1223 |
-
else:
|
| 1224 |
-
logger.info(f"[{task_id}] El destinatario {device_id if device_id else str(current_user.id)} no estaba en pendientes.")
|
| 1225 |
-
else:
|
| 1226 |
-
# Caso Estándar: Eliminar carpeta local en la laptop
|
| 1227 |
-
from tasks import eliminar_archivos_locales_task
|
| 1228 |
-
eliminar_archivos_locales_task.delay(task_id)
|
| 1229 |
-
logger.info(f"[{task_id}] Caso Estándar: Tarea de eliminación local disparada.")
|
| 1230 |
-
|
| 1231 |
-
# 2. Registrar downloaded_at en la tabla Task
|
| 1232 |
user_group_ids = [ug.group_id for ug in current_user.groups if ug.estado == 'activo']
|
| 1233 |
task_record = db.query(Task).filter(Task.task_id == task_id).filter(
|
| 1234 |
(Task.user_id == current_user.id) | (Task.groups_shared.any(Group.id.in_(user_group_ids)))
|
|
@@ -1236,20 +998,18 @@ async def confirmar_descarga(
|
|
| 1236 |
|
| 1237 |
if task_record:
|
| 1238 |
task_record.downloaded_at = datetime.utcnow()
|
| 1239 |
-
# Eliminar pista original y carpeta de stems en Supabase (por si acaso quedara algún residuo)
|
| 1240 |
-
if task_record.cloud_url:
|
| 1241 |
-
orig_path = parse_supabase_path(task_record.cloud_url)
|
| 1242 |
-
if orig_path:
|
| 1243 |
-
delete_supabase_file(orig_path)
|
| 1244 |
-
delete_supabase_folder(f"stems/{task_id}")
|
| 1245 |
db.commit()
|
| 1246 |
-
logger.info(f"[{task_id}] downloaded_at actualizado
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1247 |
else:
|
| 1248 |
logger.warning(f"[{task_id}] Tarea no encontrada para actualizar downloaded_at.")
|
| 1249 |
|
| 1250 |
return {
|
| 1251 |
"message": "Descarga confirmada",
|
| 1252 |
-
"pending_recipients":
|
| 1253 |
}
|
| 1254 |
|
| 1255 |
|
|
@@ -1309,45 +1069,15 @@ async def limpiar_tarea(
|
|
| 1309 |
except Exception as e:
|
| 1310 |
logger.warning(f"Error general al limpiar archivos de {task_id}: {e}. Se continuará con el borrado en BD.")
|
| 1311 |
|
| 1312 |
-
# Eliminar stems
|
| 1313 |
-
if task_record.telegram_message_ids:
|
| 1314 |
-
try:
|
| 1315 |
-
import json as _json
|
| 1316 |
-
from app.utils.telegram_storage import delete_telegram_message
|
| 1317 |
-
message_ids = _json.loads(task_record.telegram_message_ids)
|
| 1318 |
-
for stem_name, msg_id in message_ids.items():
|
| 1319 |
-
delete_telegram_message(int(msg_id))
|
| 1320 |
-
logger.info(f"Stem '{stem_name}' eliminado de Telegram (message_id={msg_id})")
|
| 1321 |
-
except Exception as e:
|
| 1322 |
-
logger.warning(f"Error eliminando stems de Telegram para {task_id}: {e}")
|
| 1323 |
-
|
| 1324 |
-
# Eliminar stems de Backblaze B2 si existen
|
| 1325 |
if task_record.telegram_file_ids:
|
| 1326 |
try:
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
for stem_name, stem_url in urls_dict.items():
|
| 1331 |
-
if stem_name.endswith("_pitch"):
|
| 1332 |
-
key = f"stems/{task_id}/{stem_name}.json"
|
| 1333 |
-
else:
|
| 1334 |
-
ext = "m4a" if ".m4a" in stem_url.lower() else "flac"
|
| 1335 |
-
key = f"stems/{task_id}/{stem_name}.{ext}"
|
| 1336 |
-
delete_from_b2(key)
|
| 1337 |
-
logger.info(f"[{task_id}] Stems eliminados de B2 por eliminación explícita de la pista.")
|
| 1338 |
except Exception as e:
|
| 1339 |
-
logger.warning(f"Error eliminando stems de
|
| 1340 |
|
| 1341 |
-
# Eliminar de B2SyncQueue si existe
|
| 1342 |
-
try:
|
| 1343 |
-
from app.models.sync import B2SyncQueue
|
| 1344 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 1345 |
-
if sync_entry:
|
| 1346 |
-
db.delete(sync_entry)
|
| 1347 |
-
logger.info(f"[{task_id}] Entrada eliminada de B2SyncQueue por eliminación de la pista.")
|
| 1348 |
-
except Exception as e:
|
| 1349 |
-
logger.warning(f"Error eliminando de B2SyncQueue para {task_id}: {e}")
|
| 1350 |
-
|
| 1351 |
# Eliminar pista original y stems de Supabase storage
|
| 1352 |
if task_record.cloud_url:
|
| 1353 |
orig_path = parse_supabase_path(task_record.cloud_url)
|
|
@@ -1531,342 +1261,3 @@ async def harmonize_song(
|
|
| 1531 |
return {"status": "processing", "message": "Procesamiento de armonias vocales iniciado en Celery."}
|
| 1532 |
|
| 1533 |
|
| 1534 |
-
# ==========================================
|
| 1535 |
-
# PEER-TO-PEER B2 SYNCHRONIZATION ENDPOINTS
|
| 1536 |
-
# ==========================================
|
| 1537 |
-
from typing import List, Optional
|
| 1538 |
-
from pydantic import BaseModel
|
| 1539 |
-
|
| 1540 |
-
class MissingStemItem(BaseModel):
|
| 1541 |
-
task_id: str
|
| 1542 |
-
missing_stems: List[str]
|
| 1543 |
-
|
| 1544 |
-
class ReportMissingRequest(BaseModel):
|
| 1545 |
-
device_id: Optional[str] = None
|
| 1546 |
-
missing_items: List[MissingStemItem]
|
| 1547 |
-
|
| 1548 |
-
@router.post("/sync-requests/report-missing")
|
| 1549 |
-
async def report_missing_stems(
|
| 1550 |
-
request: ReportMissingRequest,
|
| 1551 |
-
db: Session = Depends(get_db),
|
| 1552 |
-
current_user: User = Depends(get_current_user)
|
| 1553 |
-
):
|
| 1554 |
-
"""
|
| 1555 |
-
Registra que el usuario o dispositivo actual no tiene ciertos stems de una o más canciones.
|
| 1556 |
-
Crea o actualiza entradas en B2SyncQueue para coordinar la subida P2P.
|
| 1557 |
-
"""
|
| 1558 |
-
import json as _json
|
| 1559 |
-
from app.models.sync import B2SyncQueue
|
| 1560 |
-
|
| 1561 |
-
reported_tasks = []
|
| 1562 |
-
|
| 1563 |
-
for item in request.missing_items:
|
| 1564 |
-
# Verificar que la tarea existe
|
| 1565 |
-
task_record = db.query(Task).filter(Task.task_id == item.task_id).first()
|
| 1566 |
-
if not task_record:
|
| 1567 |
-
continue
|
| 1568 |
-
|
| 1569 |
-
# Verificar acceso (propietario o miembro de grupo activo)
|
| 1570 |
-
user_group_ids = [ug.group_id for ug in current_user.groups if ug.estado == 'activo']
|
| 1571 |
-
shared_gids_for_user = [gid for gid in task_record.group_ids if gid in user_group_ids]
|
| 1572 |
-
has_access = (task_record.user_id == current_user.id) or bool(shared_gids_for_user)
|
| 1573 |
-
if not has_access:
|
| 1574 |
-
continue
|
| 1575 |
-
|
| 1576 |
-
# Limpiar referencias de expiración/archivos en la nube, ya que se reportan como faltantes
|
| 1577 |
-
task_record.telegram_file_ids = None
|
| 1578 |
-
task_record.cloud_expires_at = None
|
| 1579 |
-
|
| 1580 |
-
# Determinar sync_type, target_id, y to_add
|
| 1581 |
-
target_group_id = shared_gids_for_user[0] if shared_gids_for_user else None
|
| 1582 |
-
sync_type = "group" if target_group_id is not None else "device"
|
| 1583 |
-
target_id = str(target_group_id) if sync_type == "group" else str(task_record.user_id)
|
| 1584 |
-
|
| 1585 |
-
to_add = request.device_id if request.device_id else str(current_user.id)
|
| 1586 |
-
|
| 1587 |
-
# Buscar entrada en B2SyncQueue
|
| 1588 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == item.task_id).first()
|
| 1589 |
-
|
| 1590 |
-
if not sync_entry:
|
| 1591 |
-
sync_entry = B2SyncQueue(
|
| 1592 |
-
id=item.task_id,
|
| 1593 |
-
file_key=f"stems/{item.task_id}",
|
| 1594 |
-
file_url="{}",
|
| 1595 |
-
sync_type=sync_type,
|
| 1596 |
-
target_id=target_id,
|
| 1597 |
-
pending_recipients=[to_add],
|
| 1598 |
-
is_uploaded=False,
|
| 1599 |
-
missing_stems={to_add: item.missing_stems}
|
| 1600 |
-
)
|
| 1601 |
-
db.add(sync_entry)
|
| 1602 |
-
else:
|
| 1603 |
-
to_add = request.device_id or str(current_user.id)
|
| 1604 |
-
pending = sync_entry.pending_recipients
|
| 1605 |
-
if isinstance(pending, str):
|
| 1606 |
-
pending = _json.loads(pending)
|
| 1607 |
-
pending = list(pending) if pending else []
|
| 1608 |
-
|
| 1609 |
-
if to_add not in pending:
|
| 1610 |
-
pending.append(to_add)
|
| 1611 |
-
sync_entry.pending_recipients = pending
|
| 1612 |
-
|
| 1613 |
-
missing = sync_entry.missing_stems
|
| 1614 |
-
if isinstance(missing, str):
|
| 1615 |
-
missing = _json.loads(missing)
|
| 1616 |
-
missing = dict(missing) if missing else {}
|
| 1617 |
-
|
| 1618 |
-
current_missing = set(missing.get(to_add, []))
|
| 1619 |
-
current_missing.update(item.missing_stems)
|
| 1620 |
-
missing[to_add] = list(current_missing)
|
| 1621 |
-
sync_entry.missing_stems = missing
|
| 1622 |
-
sync_entry.file_url = "{}"
|
| 1623 |
-
sync_entry.is_uploaded = False
|
| 1624 |
-
|
| 1625 |
-
db.commit()
|
| 1626 |
-
reported_tasks.append(item.task_id)
|
| 1627 |
-
|
| 1628 |
-
return {"message": "Reporte registrado", "reported_tasks": reported_tasks}
|
| 1629 |
-
|
| 1630 |
-
|
| 1631 |
-
@router.get("/sync-requests/pending")
|
| 1632 |
-
async def list_pending_sync_requests(
|
| 1633 |
-
device_id: Optional[str] = None,
|
| 1634 |
-
db: Session = Depends(get_db),
|
| 1635 |
-
current_user: User = Depends(get_current_user)
|
| 1636 |
-
):
|
| 1637 |
-
"""
|
| 1638 |
-
Retorna la lista de canciones en las que el usuario actual puede actuar como helper para subir stems.
|
| 1639 |
-
"""
|
| 1640 |
-
from datetime import datetime, timedelta
|
| 1641 |
-
from app.models.sync import B2SyncQueue
|
| 1642 |
-
|
| 1643 |
-
user_group_ids = [ug.group_id for ug in current_user.groups if ug.estado == 'activo']
|
| 1644 |
-
active_queues = db.query(B2SyncQueue).filter(B2SyncQueue.is_uploaded == False).all()
|
| 1645 |
-
|
| 1646 |
-
pending_syncs = []
|
| 1647 |
-
now = datetime.utcnow()
|
| 1648 |
-
|
| 1649 |
-
for sq in active_queues:
|
| 1650 |
-
is_locked = False
|
| 1651 |
-
if sq.uploading_by:
|
| 1652 |
-
if sq.uploading_claimed_at and now - sq.uploading_claimed_at <= timedelta(minutes=5):
|
| 1653 |
-
is_locked = True
|
| 1654 |
-
|
| 1655 |
-
if is_locked:
|
| 1656 |
-
continue
|
| 1657 |
-
|
| 1658 |
-
has_access = False
|
| 1659 |
-
is_recipient = False
|
| 1660 |
-
pending_recipients = sq.pending_recipients
|
| 1661 |
-
if isinstance(pending_recipients, str):
|
| 1662 |
-
import json as _json
|
| 1663 |
-
pending_recipients = _json.loads(pending_recipients)
|
| 1664 |
-
pending_recipients = pending_recipients or []
|
| 1665 |
-
|
| 1666 |
-
if sq.sync_type == "group":
|
| 1667 |
-
if int(sq.target_id) in user_group_ids:
|
| 1668 |
-
has_access = True
|
| 1669 |
-
if str(current_user.id) in pending_recipients or (device_id and device_id in pending_recipients):
|
| 1670 |
-
is_recipient = True
|
| 1671 |
-
else:
|
| 1672 |
-
if str(current_user.id) == sq.target_id:
|
| 1673 |
-
has_access = True
|
| 1674 |
-
if device_id and device_id in pending_recipients:
|
| 1675 |
-
is_recipient = True
|
| 1676 |
-
|
| 1677 |
-
if has_access and not is_recipient:
|
| 1678 |
-
required_stems = set()
|
| 1679 |
-
missing_stems = sq.missing_stems
|
| 1680 |
-
if isinstance(missing_stems, str):
|
| 1681 |
-
import json as _json
|
| 1682 |
-
missing_stems = _json.loads(missing_stems)
|
| 1683 |
-
|
| 1684 |
-
if missing_stems:
|
| 1685 |
-
for stems_list in missing_stems.values():
|
| 1686 |
-
required_stems.update(stems_list)
|
| 1687 |
-
|
| 1688 |
-
if not required_stems:
|
| 1689 |
-
task_record = db.query(Task).filter(Task.task_id == sq.id).first()
|
| 1690 |
-
count = task_record.stems if task_record else 4
|
| 1691 |
-
required_stems = {"vocals", "no_vocals"} if count == 2 else ({"vocals", "bass", "drums", "guitar", "piano", "other"} if count == 6 else {"vocals", "bass", "drums", "other"})
|
| 1692 |
-
|
| 1693 |
-
task_record = db.query(Task).filter(Task.task_id == sq.id).first()
|
| 1694 |
-
song_name = task_record.song_name if task_record else "Canción Melodix"
|
| 1695 |
-
|
| 1696 |
-
pending_syncs.append({
|
| 1697 |
-
"task_id": sq.id,
|
| 1698 |
-
"song_name": song_name,
|
| 1699 |
-
"required_stems": list(required_stems),
|
| 1700 |
-
"sync_type": sq.sync_type,
|
| 1701 |
-
"target_id": sq.target_id
|
| 1702 |
-
})
|
| 1703 |
-
|
| 1704 |
-
return pending_syncs
|
| 1705 |
-
|
| 1706 |
-
|
| 1707 |
-
@router.post("/sync-requests/claim-upload/{task_id}")
|
| 1708 |
-
async def claim_upload_lock(
|
| 1709 |
-
task_id: str,
|
| 1710 |
-
device_id: str = Form(...),
|
| 1711 |
-
db: Session = Depends(get_db),
|
| 1712 |
-
current_user: User = Depends(get_current_user)
|
| 1713 |
-
):
|
| 1714 |
-
"""
|
| 1715 |
-
Intenta bloquear el derecho a subir los stems para evitar subidas duplicadas de múltiples miembros.
|
| 1716 |
-
"""
|
| 1717 |
-
from datetime import datetime, timedelta
|
| 1718 |
-
from app.models.sync import B2SyncQueue
|
| 1719 |
-
|
| 1720 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 1721 |
-
if not sync_entry:
|
| 1722 |
-
raise HTTPException(status_code=404, detail="Sincronización no encontrada")
|
| 1723 |
-
|
| 1724 |
-
if sync_entry.is_uploaded:
|
| 1725 |
-
return {"success": False, "message": "Ya sincronizado"}
|
| 1726 |
-
|
| 1727 |
-
now = datetime.utcnow()
|
| 1728 |
-
if sync_entry.uploading_by and sync_entry.uploading_by != device_id:
|
| 1729 |
-
if sync_entry.uploading_claimed_at and now - sync_entry.uploading_claimed_at <= timedelta(minutes=5):
|
| 1730 |
-
raise HTTPException(status_code=409, detail="Sincronización bloqueada por otro miembro")
|
| 1731 |
-
|
| 1732 |
-
sync_entry.uploading_by = device_id
|
| 1733 |
-
sync_entry.uploading_claimed_at = now
|
| 1734 |
-
db.commit()
|
| 1735 |
-
return {"success": True, "message": "Lock adquirido"}
|
| 1736 |
-
|
| 1737 |
-
|
| 1738 |
-
@router.post("/sync-requests/upload/{task_id}/{stem_name}")
|
| 1739 |
-
async def upload_sync_stem(
|
| 1740 |
-
task_id: str,
|
| 1741 |
-
stem_name: str,
|
| 1742 |
-
file: UploadFile = File(...),
|
| 1743 |
-
db: Session = Depends(get_db),
|
| 1744 |
-
current_user: User = Depends(get_current_user)
|
| 1745 |
-
):
|
| 1746 |
-
"""
|
| 1747 |
-
El dispositivo helper sube un stem individual a la API, la cual lo deposita en B2.
|
| 1748 |
-
"""
|
| 1749 |
-
import json as _json
|
| 1750 |
-
from app.models.sync import B2SyncQueue
|
| 1751 |
-
from app.utils.b2_storage import upload_to_b2
|
| 1752 |
-
|
| 1753 |
-
task_record = db.query(Task).filter(Task.task_id == task_id).first()
|
| 1754 |
-
if not task_record:
|
| 1755 |
-
raise HTTPException(status_code=404, detail="Pista no encontrada")
|
| 1756 |
-
|
| 1757 |
-
user_group_ids = [ug.group_id for ug in current_user.groups if ug.estado == 'activo']
|
| 1758 |
-
has_access = (task_record.user_id == current_user.id) or any(gid in user_group_ids for gid in task_record.group_ids)
|
| 1759 |
-
if not has_access:
|
| 1760 |
-
raise HTTPException(status_code=403, detail="No tienes acceso a esta pista")
|
| 1761 |
-
|
| 1762 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 1763 |
-
if not sync_entry:
|
| 1764 |
-
raise HTTPException(status_code=404, detail="Sincronización no iniciada")
|
| 1765 |
-
|
| 1766 |
-
temp_dir = os.path.join(os.getcwd(), "uploads", "sync_temp")
|
| 1767 |
-
os.makedirs(temp_dir, exist_ok=True)
|
| 1768 |
-
|
| 1769 |
-
orig_ext = "flac"
|
| 1770 |
-
if file.filename:
|
| 1771 |
-
parts = file.filename.split(".")
|
| 1772 |
-
if len(parts) > 1:
|
| 1773 |
-
orig_ext = parts[-1].lower()
|
| 1774 |
-
|
| 1775 |
-
ext = "json" if stem_name.endswith("_pitch") else orig_ext
|
| 1776 |
-
temp_file_path = os.path.join(temp_dir, f"{task_id}_{stem_name}.{ext}")
|
| 1777 |
-
|
| 1778 |
-
try:
|
| 1779 |
-
with open(temp_file_path, "wb") as buffer:
|
| 1780 |
-
shutil.copyfileobj(file.file, buffer)
|
| 1781 |
-
|
| 1782 |
-
file_size = os.path.getsize(temp_file_path)
|
| 1783 |
-
logger.info(f"[{task_id}] upload_sync_stem: stem_name={stem_name}, ext={ext}, size={file_size} bytes")
|
| 1784 |
-
|
| 1785 |
-
# Idempotencia: si este stem ya fue subido a B2, no volvemos a subirlo
|
| 1786 |
-
# para evitar crear versiones duplicadas en B2.
|
| 1787 |
-
current_urls = {}
|
| 1788 |
-
if task_record.telegram_file_ids:
|
| 1789 |
-
try:
|
| 1790 |
-
current_urls = _json.loads(task_record.telegram_file_ids)
|
| 1791 |
-
except Exception:
|
| 1792 |
-
pass
|
| 1793 |
-
|
| 1794 |
-
object_key = f"stems/{task_id}/{stem_name}.{ext}"
|
| 1795 |
-
if stem_name in current_urls and current_urls[stem_name]:
|
| 1796 |
-
logger.info(f"[{task_id}] Stem '{stem_name}' ya existe en B2 ({current_urls[stem_name]}). Omitiendo re-subida.")
|
| 1797 |
-
public_url = current_urls[stem_name]
|
| 1798 |
-
else:
|
| 1799 |
-
public_url = upload_to_b2(temp_file_path, object_key)
|
| 1800 |
-
current_urls[stem_name] = public_url
|
| 1801 |
-
task_record.telegram_file_ids = _json.dumps(current_urls)
|
| 1802 |
-
|
| 1803 |
-
sync_entry.file_url = _json.dumps(current_urls)
|
| 1804 |
-
|
| 1805 |
-
# NO eliminamos de missing_stems — esa info la necesita el destinatario para saber qué descargar.
|
| 1806 |
-
# En cambio, verificamos si ya se han subido todos los stems que necesita algún destinatario,
|
| 1807 |
-
# comparando los stems presentes en file_url contra la unión de todos los stems pedidos.
|
| 1808 |
-
missing = sync_entry.missing_stems
|
| 1809 |
-
if isinstance(missing, str):
|
| 1810 |
-
missing = _json.loads(missing)
|
| 1811 |
-
missing = dict(missing) if missing else {}
|
| 1812 |
-
|
| 1813 |
-
# Unión de todos los stems que algún destinatario solicitó
|
| 1814 |
-
all_required = set()
|
| 1815 |
-
for stems_list in missing.values():
|
| 1816 |
-
if stems_list:
|
| 1817 |
-
all_required.update(stems_list)
|
| 1818 |
-
|
| 1819 |
-
# Stems ya subidos según file_url
|
| 1820 |
-
uploaded_stems = set(current_urls.keys())
|
| 1821 |
-
|
| 1822 |
-
all_uploaded = all_required.issubset(uploaded_stems)
|
| 1823 |
-
if all_uploaded and all_required:
|
| 1824 |
-
sync_entry.is_uploaded = True
|
| 1825 |
-
sync_entry.uploading_by = None
|
| 1826 |
-
sync_entry.uploading_claimed_at = None
|
| 1827 |
-
logger.info(f"[{task_id}] Todos los stems requeridos se han subido ({uploaded_stems}). Marcando is_uploaded = True de forma proactiva.")
|
| 1828 |
-
|
| 1829 |
-
# Reset de expiración en Tebi al completarse la sincronización P2P
|
| 1830 |
-
from datetime import datetime, timedelta
|
| 1831 |
-
retention_days = 7 if (bool(task_record.group_ids) or (task_record.user and task_record.user.is_premium)) else 3
|
| 1832 |
-
task_record.cloud_expires_at = datetime.utcnow() + timedelta(days=retention_days)
|
| 1833 |
-
logger.info(f"[{task_id}] Sincronización completa. cloud_expires_at reseteado a: {task_record.cloud_expires_at} ({retention_days}d)")
|
| 1834 |
-
|
| 1835 |
-
db.commit()
|
| 1836 |
-
finally:
|
| 1837 |
-
if os.path.exists(temp_file_path):
|
| 1838 |
-
os.remove(temp_file_path)
|
| 1839 |
-
|
| 1840 |
-
return {"success": True, "url": public_url}
|
| 1841 |
-
|
| 1842 |
-
|
| 1843 |
-
@router.post("/sync-requests/complete-upload/{task_id}")
|
| 1844 |
-
async def complete_sync_upload(
|
| 1845 |
-
task_id: str,
|
| 1846 |
-
db: Session = Depends(get_db),
|
| 1847 |
-
current_user: User = Depends(get_current_user)
|
| 1848 |
-
):
|
| 1849 |
-
"""
|
| 1850 |
-
El helper notifica que completó la subida de todos los stems requeridos.
|
| 1851 |
-
"""
|
| 1852 |
-
from app.models.sync import B2SyncQueue
|
| 1853 |
-
|
| 1854 |
-
sync_entry = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 1855 |
-
if not sync_entry:
|
| 1856 |
-
raise HTTPException(status_code=404, detail="Sincronización no encontrada")
|
| 1857 |
-
|
| 1858 |
-
sync_entry.is_uploaded = True
|
| 1859 |
-
sync_entry.uploading_by = None
|
| 1860 |
-
sync_entry.uploading_claimed_at = None
|
| 1861 |
-
|
| 1862 |
-
# Reset de expiración en Tebi al completarse la sincronización P2P por complete-upload
|
| 1863 |
-
task_record = db.query(Task).filter(Task.task_id == task_id).first()
|
| 1864 |
-
if task_record:
|
| 1865 |
-
from datetime import datetime, timedelta
|
| 1866 |
-
retention_days = 7 if (bool(task_record.group_ids) or (task_record.user and task_record.user.is_premium)) else 3
|
| 1867 |
-
task_record.cloud_expires_at = datetime.utcnow() + timedelta(days=retention_days)
|
| 1868 |
-
logger.info(f"[{task_id}] complete_sync_upload: cloud_expires_at reseteado a: {task_record.cloud_expires_at} ({retention_days}d)")
|
| 1869 |
-
|
| 1870 |
-
db.commit()
|
| 1871 |
-
return {"success": True}
|
| 1872 |
-
|
|
|
|
| 657 |
else:
|
| 658 |
total = db.query(Task).filter(Task.user_id == current_user.id).count()
|
| 659 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
formatted_tasks = []
|
|
|
|
| 661 |
|
| 662 |
for t in tasks:
|
|
|
|
| 663 |
sync_status = "available"
|
| 664 |
missing_stems_for_me = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
# Detectar si hay voces corales generadas en la nube (al menos 2da_voz presente)
|
| 666 |
has_harmony = False
|
| 667 |
if t.telegram_file_ids:
|
|
|
|
| 910 |
if task_record.cloud_expires_at and task_record.cloud_expires_at < datetime.utcnow():
|
| 911 |
task_record.telegram_file_ids = None
|
| 912 |
task_record.cloud_expires_at = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 913 |
db.commit()
|
| 914 |
raise HTTPException(
|
| 915 |
status_code=404,
|
|
|
|
| 919 |
if task_record.telegram_file_ids:
|
| 920 |
try:
|
| 921 |
stem_urls = _json.loads(task_record.telegram_file_ids)
|
| 922 |
+
from app.utils.hf_storage import get_hf_download_url
|
| 923 |
|
| 924 |
presigned_urls = {}
|
| 925 |
for stem_name, file_url in stem_urls.items():
|
| 926 |
if "stems/" in file_url:
|
| 927 |
# Extraer el key del objeto de forma genérica (ej. stems/task_id/stem.m4a)
|
| 928 |
+
# No depende del dominio
|
| 929 |
parts = file_url.split("stems/")
|
| 930 |
if len(parts) > 1:
|
| 931 |
object_key = "stems/" + parts[-1]
|
| 932 |
+
presigned_urls[stem_name] = get_hf_download_url(object_key)
|
| 933 |
else:
|
| 934 |
presigned_urls[stem_name] = file_url
|
| 935 |
else:
|
|
|
|
| 956 |
current_user: User = Depends(get_current_user)
|
| 957 |
):
|
| 958 |
"""
|
| 959 |
+
Limpia los metadatos de stems en la nube para forzar una resincronización.
|
| 960 |
"""
|
| 961 |
user_group_ids = [ug.group_id for ug in current_user.groups if ug.estado == 'activo']
|
| 962 |
task_record = db.query(Task).filter(Task.task_id == task_id).filter(
|
|
|
|
| 969 |
# Limpiar los metadatos de la nube
|
| 970 |
task_record.telegram_file_ids = None
|
| 971 |
task_record.cloud_expires_at = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
|
| 973 |
db.commit()
|
| 974 |
+
logger.info(f"[{task_id}] Se han reseteado los stems en la nube.")
|
| 975 |
return {"success": True, "message": "Stems en la nube reseteados correctamente."}
|
| 976 |
|
| 977 |
|
|
|
|
| 979 |
@router.post("/confirm-download/{task_id}")
|
| 980 |
async def confirmar_descarga(
|
| 981 |
task_id: str,
|
| 982 |
+
device_id: Optional[str] = Form(None),
|
| 983 |
download_type: str = "stems",
|
| 984 |
db: Session = Depends(get_db),
|
| 985 |
current_user: User = Depends(get_current_user)
|
| 986 |
):
|
| 987 |
"""
|
| 988 |
El cliente llama a este endpoint al confirmar que descargó todos los stems.
|
| 989 |
+
Registramos la descarga y disparamos la eliminación de los archivos locales en el worker de procesamiento.
|
|
|
|
|
|
|
| 990 |
"""
|
| 991 |
+
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 992 |
|
| 993 |
+
# 1. Registrar downloaded_at en la tabla Task
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
user_group_ids = [ug.group_id for ug in current_user.groups if ug.estado == 'activo']
|
| 995 |
task_record = db.query(Task).filter(Task.task_id == task_id).filter(
|
| 996 |
(Task.user_id == current_user.id) | (Task.groups_shared.any(Group.id.in_(user_group_ids)))
|
|
|
|
| 998 |
|
| 999 |
if task_record:
|
| 1000 |
task_record.downloaded_at = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
db.commit()
|
| 1002 |
+
logger.info(f"[{task_id}] downloaded_at actualizado. Disparando borrado local en el worker...")
|
| 1003 |
+
|
| 1004 |
+
# Eliminar carpeta local en el worker de procesamiento
|
| 1005 |
+
from tasks import eliminar_archivos_locales_task
|
| 1006 |
+
eliminar_archivos_locales_task.delay(task_id)
|
| 1007 |
else:
|
| 1008 |
logger.warning(f"[{task_id}] Tarea no encontrada para actualizar downloaded_at.")
|
| 1009 |
|
| 1010 |
return {
|
| 1011 |
"message": "Descarga confirmada",
|
| 1012 |
+
"pending_recipients": []
|
| 1013 |
}
|
| 1014 |
|
| 1015 |
|
|
|
|
| 1069 |
except Exception as e:
|
| 1070 |
logger.warning(f"Error general al limpiar archivos de {task_id}: {e}. Se continuará con el borrado en BD.")
|
| 1071 |
|
| 1072 |
+
# Eliminar stems de Hugging Face Dataset si existen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1073 |
if task_record.telegram_file_ids:
|
| 1074 |
try:
|
| 1075 |
+
from app.utils.hf_storage import delete_folder_from_hf
|
| 1076 |
+
delete_folder_from_hf(f"stems/{task_id}")
|
| 1077 |
+
logger.info(f"[{task_id}] Stems eliminados de Hugging Face por eliminación explícita.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
except Exception as e:
|
| 1079 |
+
logger.warning(f"Error eliminando stems de Hugging Face: {e}")
|
| 1080 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1081 |
# Eliminar pista original y stems de Supabase storage
|
| 1082 |
if task_record.cloud_url:
|
| 1083 |
orig_path = parse_supabase_path(task_record.cloud_url)
|
|
|
|
| 1261 |
return {"status": "processing", "message": "Procesamiento de armonias vocales iniciado en Celery."}
|
| 1262 |
|
| 1263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/utils/b2_storage.py
DELETED
|
@@ -1,192 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
|
| 3 |
-
# Set AWS checksum environment variables to fix Tebi boto3 issue
|
| 4 |
-
os.environ["AWS_REQUEST_CHECKSUM_CALCULATION"] = "when_required"
|
| 5 |
-
os.environ["AWS_RESPONSE_CHECKSUM_VALIDATION"] = "when_required"
|
| 6 |
-
|
| 7 |
-
import boto3
|
| 8 |
-
from botocore.config import Config
|
| 9 |
-
import logging
|
| 10 |
-
|
| 11 |
-
logger = logging.getLogger(__name__)
|
| 12 |
-
|
| 13 |
-
B2_KEY_ID = os.getenv("B2_KEY_ID")
|
| 14 |
-
B2_APPLICATION_KEY = os.getenv("B2_APPLICATION_KEY")
|
| 15 |
-
B2_ENDPOINT = os.getenv("B2_ENDPOINT") # e.g., https://s3.us-east-005.backblazeb2.com
|
| 16 |
-
B2_BUCKET_NAME = os.getenv("B2_BUCKET_NAME")
|
| 17 |
-
|
| 18 |
-
def get_b2_client():
|
| 19 |
-
if not all([B2_KEY_ID, B2_APPLICATION_KEY, B2_ENDPOINT]):
|
| 20 |
-
logger.warning("Faltan variables de entorno para almacenamiento S3. Usando Mock.")
|
| 21 |
-
return None
|
| 22 |
-
|
| 23 |
-
# Detectar región según proveedor
|
| 24 |
-
endpoint_lower = B2_ENDPOINT.lower()
|
| 25 |
-
if "tebi.io" in endpoint_lower:
|
| 26 |
-
# Tebi usa "auto" como región y requiere SigV4
|
| 27 |
-
region = "auto"
|
| 28 |
-
elif "backblazeb2.com" in endpoint_lower:
|
| 29 |
-
# Deducir región del endpoint B2 (ej. s3.us-east-005.backblazeb2.com → us-east-005)
|
| 30 |
-
region = "us-east-005"
|
| 31 |
-
try:
|
| 32 |
-
parts = B2_ENDPOINT.replace("https://", "").replace("http://", "").split(".")
|
| 33 |
-
if len(parts) >= 2 and "s3" in parts[0]:
|
| 34 |
-
region = parts[1]
|
| 35 |
-
except Exception as e:
|
| 36 |
-
logger.warning(f"No se pudo deducir la región de B2, usando {region}: {e}")
|
| 37 |
-
else:
|
| 38 |
-
# Fallback genérico para otros proveedores S3-compatibles
|
| 39 |
-
region = "us-east-1"
|
| 40 |
-
|
| 41 |
-
return boto3.client(
|
| 42 |
-
's3',
|
| 43 |
-
endpoint_url=B2_ENDPOINT,
|
| 44 |
-
aws_access_key_id=B2_KEY_ID,
|
| 45 |
-
aws_secret_access_key=B2_APPLICATION_KEY,
|
| 46 |
-
region_name=region,
|
| 47 |
-
config=Config(signature_version='s3v4', s3={'addressing_style': 'path'})
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
def upload_to_b2(file_path: str, object_name: str) -> str:
|
| 51 |
-
"""
|
| 52 |
-
Sube un archivo local a Backblaze B2 y retorna su URL pública.
|
| 53 |
-
Actúa como upsert: elimina versiones anteriores del mismo objeto antes de subir
|
| 54 |
-
para evitar acumulación de versiones duplicadas en B2.
|
| 55 |
-
"""
|
| 56 |
-
client = get_b2_client()
|
| 57 |
-
if not client:
|
| 58 |
-
logger.warning(f"[Mock B2] Subiendo {file_path} como {object_name}")
|
| 59 |
-
return f"https://mock-b2.backblazeb2.com/{B2_BUCKET_NAME or 'mock'}/{object_name}"
|
| 60 |
-
|
| 61 |
-
try:
|
| 62 |
-
# Eliminar versiones previas del mismo objeto antes de subir
|
| 63 |
-
try:
|
| 64 |
-
versions_resp = client.list_object_versions(Bucket=B2_BUCKET_NAME, Prefix=object_name)
|
| 65 |
-
old_versions = []
|
| 66 |
-
for version in versions_resp.get('Versions', []):
|
| 67 |
-
if version.get('Key') == object_name:
|
| 68 |
-
old_versions.append({'Key': object_name, 'VersionId': version.get('VersionId')})
|
| 69 |
-
for marker in versions_resp.get('DeleteMarkers', []):
|
| 70 |
-
if marker.get('Key') == object_name:
|
| 71 |
-
old_versions.append({'Key': object_name, 'VersionId': marker.get('VersionId')})
|
| 72 |
-
if old_versions:
|
| 73 |
-
client.delete_objects(
|
| 74 |
-
Bucket=B2_BUCKET_NAME,
|
| 75 |
-
Delete={'Objects': old_versions, 'Quiet': True}
|
| 76 |
-
)
|
| 77 |
-
logger.info(f"Eliminadas {len(old_versions)} versiones previas de B2 para: {object_name}")
|
| 78 |
-
except Exception as cleanup_err:
|
| 79 |
-
logger.warning(f"No se pudieron limpiar versiones previas de {object_name}: {cleanup_err}")
|
| 80 |
-
|
| 81 |
-
client.upload_file(file_path, B2_BUCKET_NAME, object_name)
|
| 82 |
-
endpoint_host = B2_ENDPOINT.replace("https://", "").replace("http://", "")
|
| 83 |
-
public_url = f"https://{B2_BUCKET_NAME}.{endpoint_host}/{object_name}"
|
| 84 |
-
logger.info(f"Archivo subido a B2: {object_name}. URL: {public_url}")
|
| 85 |
-
return public_url
|
| 86 |
-
except Exception as e:
|
| 87 |
-
logger.error(f"Error subiendo archivo {object_name} a B2: {e}")
|
| 88 |
-
raise e
|
| 89 |
-
|
| 90 |
-
def delete_from_b2(object_name: str):
|
| 91 |
-
"""
|
| 92 |
-
Elimina un archivo de Backblaze B2 de manera permanente (hard delete),
|
| 93 |
-
removiendo todas sus versiones y delete markers para evitar que queden en la papelera.
|
| 94 |
-
"""
|
| 95 |
-
client = get_b2_client()
|
| 96 |
-
if not client:
|
| 97 |
-
logger.warning(f"[Mock B2] Eliminando {object_name}")
|
| 98 |
-
return
|
| 99 |
-
|
| 100 |
-
try:
|
| 101 |
-
# Listar todas las versiones y delete markers para este objeto específico
|
| 102 |
-
versions = client.list_object_versions(Bucket=B2_BUCKET_NAME, Prefix=object_name)
|
| 103 |
-
|
| 104 |
-
objects_to_delete = []
|
| 105 |
-
|
| 106 |
-
# Recolectar versiones
|
| 107 |
-
for version in versions.get('Versions', []):
|
| 108 |
-
if version.get('Key') == object_name:
|
| 109 |
-
objects_to_delete.append({'Key': object_name, 'VersionId': version.get('VersionId')})
|
| 110 |
-
|
| 111 |
-
# Recolectar delete markers
|
| 112 |
-
for marker in versions.get('DeleteMarkers', []):
|
| 113 |
-
if marker.get('Key') == object_name:
|
| 114 |
-
objects_to_delete.append({'Key': object_name, 'VersionId': marker.get('VersionId')})
|
| 115 |
-
|
| 116 |
-
if objects_to_delete:
|
| 117 |
-
client.delete_objects(
|
| 118 |
-
Bucket=B2_BUCKET_NAME,
|
| 119 |
-
Delete={
|
| 120 |
-
'Objects': objects_to_delete,
|
| 121 |
-
'Quiet': True
|
| 122 |
-
}
|
| 123 |
-
)
|
| 124 |
-
logger.info(f"Archivo eliminado permanentemente de B2 (incluyendo {len(objects_to_delete)} versiones/delete markers): {object_name}")
|
| 125 |
-
else:
|
| 126 |
-
# Fallback en caso de que list_object_versions no devuelva nada o no coincida
|
| 127 |
-
client.delete_object(Bucket=B2_BUCKET_NAME, Key=object_name)
|
| 128 |
-
logger.info(f"Archivo eliminado de B2 (simple delete fallback): {object_name}")
|
| 129 |
-
|
| 130 |
-
except Exception as e:
|
| 131 |
-
logger.error(f"Error eliminando permanentemente de B2 ({object_name}): {e}")
|
| 132 |
-
|
| 133 |
-
def get_b2_download_url(object_name: str, expires_in: int = 3600) -> str:
|
| 134 |
-
"""
|
| 135 |
-
Genera una URL firmada (presigned) para descargar un archivo de Backblaze B2 de manera segura.
|
| 136 |
-
Soporta buckets privados sin requerir que el usuario lo cambie a público.
|
| 137 |
-
"""
|
| 138 |
-
client = get_b2_client()
|
| 139 |
-
if not client:
|
| 140 |
-
return f"https://mock-b2.backblazeb2.com/{B2_BUCKET_NAME or 'mock'}/{object_name}"
|
| 141 |
-
|
| 142 |
-
try:
|
| 143 |
-
url = client.generate_presigned_url(
|
| 144 |
-
'get_object',
|
| 145 |
-
Params={
|
| 146 |
-
'Bucket': B2_BUCKET_NAME,
|
| 147 |
-
'Key': object_name
|
| 148 |
-
},
|
| 149 |
-
ExpiresIn=expires_in
|
| 150 |
-
)
|
| 151 |
-
logger.info(f"URL firmada generada para {object_name}: {url[:60]}...")
|
| 152 |
-
return url
|
| 153 |
-
except Exception as e:
|
| 154 |
-
logger.error(f"Error generando URL firmada para {object_name}: {e}")
|
| 155 |
-
endpoint_host = B2_ENDPOINT.replace("https://", "").replace("http://", "")
|
| 156 |
-
return f"https://{B2_BUCKET_NAME}.{endpoint_host}/{object_name}"
|
| 157 |
-
|
| 158 |
-
def delete_folder_from_b2(prefix: str):
|
| 159 |
-
"""
|
| 160 |
-
Elimina todos los objetos en Backblaze B2 que coincidan con el prefijo dado (ej. migrations/user_id/).
|
| 161 |
-
"""
|
| 162 |
-
client = get_b2_client()
|
| 163 |
-
if not client:
|
| 164 |
-
logger.warning(f"[Mock B2] Eliminando carpeta con prefijo: {prefix}")
|
| 165 |
-
return
|
| 166 |
-
|
| 167 |
-
try:
|
| 168 |
-
# Listar objetos con el prefijo
|
| 169 |
-
paginator = client.get_paginator('list_objects_v2')
|
| 170 |
-
pages = paginator.paginate(Bucket=B2_BUCKET_NAME, Prefix=prefix)
|
| 171 |
-
|
| 172 |
-
delete_keys = []
|
| 173 |
-
for page in pages:
|
| 174 |
-
for obj in page.get('Contents', []):
|
| 175 |
-
delete_keys.append({'Key': obj['Key']})
|
| 176 |
-
|
| 177 |
-
if delete_keys:
|
| 178 |
-
# Dividir en lotes de 1000 (límite de delete_objects en S3 API)
|
| 179 |
-
for i in range(0, len(delete_keys), 1000):
|
| 180 |
-
batch = delete_keys[i:i+1000]
|
| 181 |
-
client.delete_objects(
|
| 182 |
-
Bucket=B2_BUCKET_NAME,
|
| 183 |
-
Delete={
|
| 184 |
-
'Objects': batch,
|
| 185 |
-
'Quiet': True
|
| 186 |
-
}
|
| 187 |
-
)
|
| 188 |
-
logger.info(f"Eliminados {len(delete_keys)} objetos con prefijo '{prefix}' de B2.")
|
| 189 |
-
else:
|
| 190 |
-
logger.info(f"No se encontraron objetos con el prefijo '{prefix}' para eliminar.")
|
| 191 |
-
except Exception as e:
|
| 192 |
-
logger.error(f"Error eliminando carpeta '{prefix}' de B2: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/utils/hf_storage.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from huggingface_hub import HfApi
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 8 |
+
HF_STORAGE_REPO = os.getenv("HF_STORAGE_REPO", "Isaac105/melodix-stems")
|
| 9 |
+
|
| 10 |
+
def get_hf_api():
|
| 11 |
+
if not HF_TOKEN:
|
| 12 |
+
logger.warning("Falta variable de entorno HF_TOKEN. Operaciones de Hugging Face desactivadas.")
|
| 13 |
+
return None
|
| 14 |
+
return HfApi(token=HF_TOKEN)
|
| 15 |
+
|
| 16 |
+
def upload_to_hf(file_path: str, object_name: str) -> str:
|
| 17 |
+
"""
|
| 18 |
+
Sube un archivo local al dataset de Hugging Face y retorna su URL pública de descarga.
|
| 19 |
+
"""
|
| 20 |
+
api = get_hf_api()
|
| 21 |
+
if not api:
|
| 22 |
+
logger.warning(f"[Mock HF] Subiendo {file_path} como {object_name}")
|
| 23 |
+
return f"https://mock-hf.huggingface.co/{HF_STORAGE_REPO}/{object_name}"
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
api.upload_file(
|
| 27 |
+
path_or_fileobj=file_path,
|
| 28 |
+
path_in_repo=object_name,
|
| 29 |
+
repo_id=HF_STORAGE_REPO,
|
| 30 |
+
repo_type="dataset"
|
| 31 |
+
)
|
| 32 |
+
public_url = get_hf_download_url(object_name)
|
| 33 |
+
logger.info(f"Archivo subido a Hugging Face Dataset: {object_name}. URL: {public_url}")
|
| 34 |
+
return public_url
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"Error subiendo archivo {object_name} a Hugging Face: {e}")
|
| 37 |
+
raise e
|
| 38 |
+
|
| 39 |
+
def delete_from_hf(object_name: str):
|
| 40 |
+
"""
|
| 41 |
+
Elimina un archivo del dataset de Hugging Face.
|
| 42 |
+
"""
|
| 43 |
+
api = get_hf_api()
|
| 44 |
+
if not api:
|
| 45 |
+
logger.warning(f"[Mock HF] Eliminando {object_name}")
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
api.delete_file(
|
| 50 |
+
path_in_repo=object_name,
|
| 51 |
+
repo_id=HF_STORAGE_REPO,
|
| 52 |
+
repo_type="dataset"
|
| 53 |
+
)
|
| 54 |
+
logger.info(f"Archivo eliminado de Hugging Face Dataset: {object_name}")
|
| 55 |
+
except Exception as e:
|
| 56 |
+
if "EntryNotFound" in str(e) or "404" in str(e):
|
| 57 |
+
logger.info(f"El archivo {object_name} no existía en Hugging Face. Ignorado.")
|
| 58 |
+
else:
|
| 59 |
+
logger.error(f"Error eliminando de Hugging Face ({object_name}): {e}")
|
| 60 |
+
|
| 61 |
+
def delete_folder_from_hf(prefix: str):
|
| 62 |
+
"""
|
| 63 |
+
Elimina una carpeta completa (ej. stems/task_id/) del dataset de Hugging Face.
|
| 64 |
+
"""
|
| 65 |
+
api = get_hf_api()
|
| 66 |
+
if not api:
|
| 67 |
+
logger.warning(f"[Mock HF] Eliminando carpeta con prefijo: {prefix}")
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
api.delete_folder(
|
| 72 |
+
folder_path=prefix,
|
| 73 |
+
repo_id=HF_STORAGE_REPO,
|
| 74 |
+
repo_type="dataset"
|
| 75 |
+
)
|
| 76 |
+
logger.info(f"Carpeta eliminada de Hugging Face Dataset: {prefix}")
|
| 77 |
+
except Exception as e:
|
| 78 |
+
if "EntryNotFound" in str(e) or "404" in str(e):
|
| 79 |
+
logger.info(f"La carpeta {prefix} no existía en Hugging Face. Ignorado.")
|
| 80 |
+
else:
|
| 81 |
+
logger.error(f"Error eliminando carpeta '{prefix}' de Hugging Face: {e}")
|
| 82 |
+
|
| 83 |
+
def get_hf_download_url(object_name: str) -> str:
|
| 84 |
+
"""
|
| 85 |
+
Devuelve la URL de descarga directa pública para un objeto en el dataset de Hugging Face.
|
| 86 |
+
"""
|
| 87 |
+
return f"https://huggingface.co/datasets/{HF_STORAGE_REPO}/resolve/main/{object_name}"
|
cleanup.py
CHANGED
|
@@ -209,71 +209,32 @@ def show_stats():
|
|
| 209 |
|
| 210 |
|
| 211 |
# ==========================================
|
| 212 |
-
# EXPIRACIÓN DE
|
| 213 |
# ==========================================
|
| 214 |
|
| 215 |
def clean_expired_sync_queue(max_age_days=7, dry_run=False):
|
| 216 |
"""
|
| 217 |
-
|
| 218 |
-
y purga
|
| 219 |
"""
|
| 220 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 223 |
if current_dir not in sys.path:
|
| 224 |
sys.path.insert(0, current_dir)
|
| 225 |
|
| 226 |
try:
|
| 227 |
-
from
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
except ImportError as ie:
|
| 232 |
-
logger.error(f"Error de importación al limpiar cola de sync: {ie}")
|
| 233 |
-
return 0
|
| 234 |
-
|
| 235 |
-
db = SessionLocal()
|
| 236 |
-
cutoff = datetime.utcnow() - timedelta(days=max_age_days)
|
| 237 |
-
cleaned = 0
|
| 238 |
-
|
| 239 |
-
try:
|
| 240 |
-
expired_entries = db.query(B2SyncQueue).filter(B2SyncQueue.created_at < cutoff).all()
|
| 241 |
-
logger.info(f"Encontradas {len(expired_entries)} entradas sync vencidas.")
|
| 242 |
-
|
| 243 |
-
for entry in expired_entries:
|
| 244 |
-
task_id = entry.id
|
| 245 |
-
if dry_run:
|
| 246 |
-
logger.info(f" [DRY-RUN] Eliminaría entrada sync {task_id} creada el {entry.created_at}")
|
| 247 |
-
else:
|
| 248 |
-
logger.info(f" Limpiando entrada sync {task_id} (creada {entry.created_at})...")
|
| 249 |
-
# Eliminar de B2
|
| 250 |
-
try:
|
| 251 |
-
if entry.file_url:
|
| 252 |
-
urls_dict = json.loads(entry.file_url)
|
| 253 |
-
for stem_name, stem_url in urls_dict.items():
|
| 254 |
-
if stem_name.endswith("_pitch"):
|
| 255 |
-
key = f"stems/{task_id}/{stem_name}.json"
|
| 256 |
-
else:
|
| 257 |
-
ext = "m4a" if ".m4a" in stem_url.lower() else "flac"
|
| 258 |
-
key = f"stems/{task_id}/{stem_name}.{ext}"
|
| 259 |
-
delete_from_b2(key)
|
| 260 |
-
except Exception as b2_err:
|
| 261 |
-
logger.error(f" Error purgando archivos B2 para {task_id}: {b2_err}")
|
| 262 |
-
|
| 263 |
-
# Eliminar de BD
|
| 264 |
-
db.delete(entry)
|
| 265 |
-
cleaned += 1
|
| 266 |
-
|
| 267 |
-
if not dry_run and cleaned > 0:
|
| 268 |
-
db.commit()
|
| 269 |
-
|
| 270 |
-
logger.info(f"Cola de sincronización B2 limpia: {cleaned} entradas eliminadas.")
|
| 271 |
return cleaned
|
| 272 |
except Exception as e:
|
| 273 |
-
logger.error(f"Error
|
| 274 |
return 0
|
| 275 |
-
finally:
|
| 276 |
-
db.close()
|
| 277 |
|
| 278 |
|
| 279 |
# ==========================================
|
|
@@ -306,7 +267,7 @@ def main():
|
|
| 306 |
logger.info("")
|
| 307 |
cleaned_logs = clean_logs(max_age_days=30, dry_run=args.dry_run) # Logs: 30 días
|
| 308 |
logger.info("")
|
| 309 |
-
cleaned_sync = clean_expired_sync_queue(max_age_days=args.days, dry_run=args.dry_run) #
|
| 310 |
|
| 311 |
logger.info("")
|
| 312 |
logger.info("=" * 50)
|
|
@@ -315,7 +276,7 @@ def main():
|
|
| 315 |
logger.info(f"Uploads limpiados: {cleaned_uploads}")
|
| 316 |
logger.info(f"Procesados limpiados: {cleaned_processed}")
|
| 317 |
logger.info(f"Logs limpiados: {cleaned_logs}")
|
| 318 |
-
logger.info(f"
|
| 319 |
logger.info("=" * 50)
|
| 320 |
|
| 321 |
if args.dry_run:
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
# ==========================================
|
| 212 |
+
# EXPIRACIÓN DE ALMACENAMIENTO EN HUGGING FACE
|
| 213 |
# ==========================================
|
| 214 |
|
| 215 |
def clean_expired_sync_queue(max_age_days=7, dry_run=False):
|
| 216 |
"""
|
| 217 |
+
Ejecuta la limpieza de archivos expirados en Hugging Face Dataset
|
| 218 |
+
y la purga mensual de usuarios normales si corresponde.
|
| 219 |
"""
|
| 220 |
+
logger.info("Ejecutando limpieza de archivos expirados en Hugging Face Datasets...")
|
| 221 |
+
if dry_run:
|
| 222 |
+
logger.info(" [DRY-RUN] Ejecutaría la tarea de limpieza en Hugging Face.")
|
| 223 |
+
return 0
|
| 224 |
|
| 225 |
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 226 |
if current_dir not in sys.path:
|
| 227 |
sys.path.insert(0, current_dir)
|
| 228 |
|
| 229 |
try:
|
| 230 |
+
from tasks import limpiar_stems_expirados_task
|
| 231 |
+
res = limpiar_stems_expirados_task()
|
| 232 |
+
cleaned = res.get("cleaned", 0)
|
| 233 |
+
logger.info(f"Limpieza en Hugging Face completada. Tareas limpiadas: {cleaned}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
return cleaned
|
| 235 |
except Exception as e:
|
| 236 |
+
logger.error(f"Error ejecutando limpiar_stems_expirados_task desde cleanup.py: {e}")
|
| 237 |
return 0
|
|
|
|
|
|
|
| 238 |
|
| 239 |
|
| 240 |
# ==========================================
|
|
|
|
| 267 |
logger.info("")
|
| 268 |
cleaned_logs = clean_logs(max_age_days=30, dry_run=args.dry_run) # Logs: 30 días
|
| 269 |
logger.info("")
|
| 270 |
+
cleaned_sync = clean_expired_sync_queue(max_age_days=args.days, dry_run=args.dry_run) # HF/Expira
|
| 271 |
|
| 272 |
logger.info("")
|
| 273 |
logger.info("=" * 50)
|
|
|
|
| 276 |
logger.info(f"Uploads limpiados: {cleaned_uploads}")
|
| 277 |
logger.info(f"Procesados limpiados: {cleaned_processed}")
|
| 278 |
logger.info(f"Logs limpiados: {cleaned_logs}")
|
| 279 |
+
logger.info(f"HF/Nube limpiados: {cleaned_sync}")
|
| 280 |
logger.info("=" * 50)
|
| 281 |
|
| 282 |
if args.dry_run:
|
tasks.py
CHANGED
|
@@ -814,11 +814,11 @@ def procesar_cancion_completa(self, file_path, task_id, stems, trim_start="", tr
|
|
| 814 |
except Exception as db_err:
|
| 815 |
logger.error(f"[{task_id}] Error determinando premium/grupo: {db_err}")
|
| 816 |
|
| 817 |
-
logger.info(f"[{task_id}] Subiendo stems a
|
| 818 |
-
self.update_state(state='PROGRESS', meta={'progress': 95, 'message': 'Subiendo stems a
|
| 819 |
|
| 820 |
-
|
| 821 |
-
from app.utils.
|
| 822 |
|
| 823 |
for stem_name, stem_path, ext in stems_to_upload:
|
| 824 |
try:
|
|
@@ -826,70 +826,42 @@ def procesar_cancion_completa(self, file_path, task_id, stems, trim_start="", tr
|
|
| 826 |
raise TaskCancelledException("Tarea cancelada por el usuario.")
|
| 827 |
|
| 828 |
object_name = f"stems/{task_id}/{stem_name}.{ext}"
|
| 829 |
-
public_url =
|
| 830 |
-
|
| 831 |
|
| 832 |
-
# Eliminar de disco local de inmediato tras subir
|
| 833 |
if os.path.exists(stem_path):
|
| 834 |
os.remove(stem_path)
|
| 835 |
except Exception as stem_err:
|
| 836 |
-
logger.warning(f"[{task_id}] Fallo al subir '{stem_name}' a
|
| 837 |
if isinstance(stem_err, TaskCancelledException):
|
| 838 |
raise stem_err
|
| 839 |
|
| 840 |
-
# Guardar URLs y
|
| 841 |
-
if
|
| 842 |
try:
|
| 843 |
from datetime import timedelta
|
| 844 |
db = SessionLocal()
|
| 845 |
task_record = db.query(TaskModel).filter(TaskModel.task_id == task_id).first()
|
| 846 |
if task_record:
|
| 847 |
-
task_record.telegram_file_ids = json.dumps(
|
| 848 |
if acordes:
|
| 849 |
task_record.chords = json.dumps(acordes)
|
| 850 |
task_record.status = "completed"
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
if group_id is not None:
|
| 862 |
-
sync_type = "group"
|
| 863 |
-
target_id = str(group_id)
|
| 864 |
-
from app.models.lyrics import UserGroup as UserGroupModel
|
| 865 |
-
from app.models.user import Device as DeviceModel
|
| 866 |
-
active_members = db.query(UserGroupModel).filter(
|
| 867 |
-
UserGroupModel.group_id == group_id,
|
| 868 |
-
UserGroupModel.estado == "activo"
|
| 869 |
-
).all()
|
| 870 |
-
member_ids = [m.user_id for m in active_members]
|
| 871 |
-
devices = db.query(DeviceModel).filter(DeviceModel.user_id.in_(member_ids)).all()
|
| 872 |
-
pending_recipients = [d.device_id for d in devices]
|
| 873 |
-
else:
|
| 874 |
-
sync_type = "device"
|
| 875 |
-
target_id = str(task_record.user_id) if task_record else ""
|
| 876 |
-
pending_recipients = user_devices
|
| 877 |
-
|
| 878 |
-
from app.models.sync import B2SyncQueue as B2SyncQueueModel
|
| 879 |
-
sync_entry = B2SyncQueueModel(
|
| 880 |
-
id=task_id,
|
| 881 |
-
file_key=f"stems/{task_id}",
|
| 882 |
-
file_url=json.dumps(b2_urls),
|
| 883 |
-
sync_type=sync_type,
|
| 884 |
-
target_id=target_id,
|
| 885 |
-
pending_recipients=pending_recipients
|
| 886 |
-
)
|
| 887 |
-
db.add(sync_entry)
|
| 888 |
db.commit()
|
| 889 |
db.close()
|
| 890 |
-
logger.info(f"[{task_id}] Registrado en B2SyncQueue ({sync_type}) con {len(pending_recipients)} pendientes.")
|
| 891 |
except Exception as db_err:
|
| 892 |
-
logger.error(f"[{task_id}] Error al registrar en DB
|
| 893 |
|
| 894 |
# Limpiar la carpeta física local output_base de inmediato para liberar espacio en el disco del worker
|
| 895 |
try:
|
|
@@ -1000,10 +972,9 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1000 |
try:
|
| 1001 |
from app.database import SessionLocal
|
| 1002 |
from app.models.process import Task as TaskModel
|
| 1003 |
-
from app.models.sync import B2SyncQueue as B2SyncQueueModel
|
| 1004 |
from app.utils.redis_client import get_redis_client
|
| 1005 |
from app.utils.harmony import generar_armonias
|
| 1006 |
-
from app.utils.
|
| 1007 |
import json
|
| 1008 |
import os
|
| 1009 |
import shutil
|
|
@@ -1037,9 +1008,9 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1037 |
if os.path.exists(flac_vocal):
|
| 1038 |
vocals_path = flac_vocal
|
| 1039 |
|
| 1040 |
-
# Si vocals no está localmente, descargarlo de
|
| 1041 |
if not os.path.exists(vocals_path):
|
| 1042 |
-
logger.info(f"[{task_id}] vocals no se encuentra localmente. Descargándolo de
|
| 1043 |
try:
|
| 1044 |
db = SessionLocal()
|
| 1045 |
task_record = db.query(TaskModel).filter(TaskModel.task_id == task_id).first()
|
|
@@ -1058,9 +1029,9 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1058 |
ext = "m4a" if ".m4a" in vocals_url.lower() else "flac"
|
| 1059 |
vocals_path = os.path.join(song_dir, f"vocals.{ext}")
|
| 1060 |
|
| 1061 |
-
# Generar URL
|
| 1062 |
object_key = f"stems/{task_id}/vocals.{ext}"
|
| 1063 |
-
download_url =
|
| 1064 |
|
| 1065 |
# Descargar archivo
|
| 1066 |
r = requests.get(download_url, stream=True)
|
|
@@ -1068,9 +1039,9 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1068 |
with open(vocals_path, 'wb') as f:
|
| 1069 |
for chunk in r.iter_content(chunk_size=8192):
|
| 1070 |
f.write(chunk)
|
| 1071 |
-
logger.info(f"[{task_id}] vocals.{ext} descargado con éxito de
|
| 1072 |
except Exception as dl_err:
|
| 1073 |
-
logger.error(f"[{task_id}] Error descargando vocals de
|
| 1074 |
if redis_client:
|
| 1075 |
redis_client.set(f"harmony_status:{task_id}", "error", ex=3600)
|
| 1076 |
return False
|
|
@@ -1080,9 +1051,9 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1080 |
if not res or not res.get("success"):
|
| 1081 |
raise Exception(res.get("error", "Error desconocido al armonizar."))
|
| 1082 |
|
| 1083 |
-
logger.info(f"[{task_id}] Armonías y trayectorias generadas localmente. Subiendo a
|
| 1084 |
|
| 1085 |
-
# 3. Subir a
|
| 1086 |
voces_corales = [
|
| 1087 |
"2da_voz", "2da_voz_baja", "3ra_voz",
|
| 1088 |
"3ra_voz_baja", "oct_alta", "oct_baja"
|
|
@@ -1103,7 +1074,7 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1103 |
|
| 1104 |
if os.path.exists(file_path):
|
| 1105 |
object_key = f"stems/{task_id}/{v}.{ext}"
|
| 1106 |
-
public_url =
|
| 1107 |
uploaded_files[v] = public_url
|
| 1108 |
|
| 1109 |
# Subir jsons
|
|
@@ -1111,7 +1082,7 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1111 |
file_path = os.path.join(song_dir, pj)
|
| 1112 |
if os.path.exists(file_path):
|
| 1113 |
object_key = f"stems/{task_id}/{pj}"
|
| 1114 |
-
public_url =
|
| 1115 |
pitch_key = pj.replace(".json", "")
|
| 1116 |
uploaded_files[pitch_key] = public_url
|
| 1117 |
|
|
@@ -1131,24 +1102,23 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1131 |
current_urls.update(uploaded_files)
|
| 1132 |
task_record.telegram_file_ids = json.dumps(current_urls)
|
| 1133 |
|
| 1134 |
-
# Si cloud_expires_at no está seteado aún
|
| 1135 |
-
# calcularlo ahora basado en el usuario
|
| 1136 |
if task_record.cloud_expires_at is None:
|
| 1137 |
try:
|
| 1138 |
user = db.query(UserModel).filter(UserModel.id == task_record.user_id).first()
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1142 |
except Exception as exp_err:
|
| 1143 |
logger.warning(f"[{task_id}] No se pudo calcular cloud_expires_at en armonías: {exp_err}")
|
| 1144 |
|
| 1145 |
-
# Actualizar también B2SyncQueue
|
| 1146 |
-
sync_entry = db.query(B2SyncQueueModel).filter(B2SyncQueueModel.id == task_id).first()
|
| 1147 |
-
if sync_entry:
|
| 1148 |
-
sync_entry.file_url = json.dumps(current_urls)
|
| 1149 |
-
|
| 1150 |
db.commit()
|
| 1151 |
-
logger.info(f"[{task_id}] Registro de base de datos actualizado con URLs corales.")
|
| 1152 |
db.close()
|
| 1153 |
|
| 1154 |
# 5. Limpieza de archivos generados localmente
|
|
@@ -1192,28 +1162,26 @@ def generar_armonias_task(self, task_id: str):
|
|
| 1192 |
@celery_app.task(name="limpiar_stems_expirados_task")
|
| 1193 |
def limpiar_stems_expirados_task():
|
| 1194 |
"""
|
| 1195 |
-
Elimina de
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
Diseñada para ser invocada por un endpoint HTTP (POST /songs/cleanup-expired)
|
| 1199 |
-
llamado por el cron-job externo cada hora. No requiere Celery Beat.
|
| 1200 |
"""
|
| 1201 |
from app.database import SessionLocal
|
| 1202 |
from app.models.process import Task as TaskModel
|
| 1203 |
-
from app.
|
| 1204 |
-
from app.utils.b2_storage import delete_folder_from_b2
|
| 1205 |
from datetime import datetime
|
| 1206 |
|
| 1207 |
-
logger.info("[Cleanup] Iniciando limpieza de stems expirados en
|
| 1208 |
db = SessionLocal()
|
| 1209 |
cleaned = 0
|
| 1210 |
errors = 0
|
| 1211 |
|
| 1212 |
try:
|
| 1213 |
now = datetime.utcnow()
|
|
|
|
| 1214 |
expired_tasks = db.query(TaskModel).filter(
|
| 1215 |
TaskModel.cloud_expires_at <= now,
|
| 1216 |
-
TaskModel.telegram_file_ids != None
|
| 1217 |
).all()
|
| 1218 |
|
| 1219 |
logger.info(f"[Cleanup] {len(expired_tasks)} tasks expiradas encontradas.")
|
|
@@ -1221,26 +1189,52 @@ def limpiar_stems_expirados_task():
|
|
| 1221 |
for task in expired_tasks:
|
| 1222 |
try:
|
| 1223 |
task_id = task.task_id
|
| 1224 |
-
logger.info(f"[Cleanup] Eliminando
|
| 1225 |
|
| 1226 |
-
#
|
| 1227 |
-
|
| 1228 |
|
| 1229 |
-
#
|
| 1230 |
task.telegram_file_ids = None
|
| 1231 |
task.cloud_expires_at = None
|
| 1232 |
|
| 1233 |
-
# 3. Eliminar entrada de B2SyncQueue
|
| 1234 |
-
sync_entry = db.query(B2SyncQueueModel).filter(B2SyncQueueModel.id == task_id).first()
|
| 1235 |
-
if sync_entry:
|
| 1236 |
-
db.delete(sync_entry)
|
| 1237 |
-
|
| 1238 |
cleaned += 1
|
| 1239 |
-
logger.info(f"[Cleanup] Task {task_id}
|
| 1240 |
except Exception as e:
|
| 1241 |
errors += 1
|
| 1242 |
logger.error(f"[Cleanup] Error limpiando task {task.task_id}: {e}")
|
| 1243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1244 |
db.commit()
|
| 1245 |
logger.info(f"[Cleanup] Finalizado. Limpiadas: {cleaned}, Errores: {errors}")
|
| 1246 |
return {"cleaned": cleaned, "errors": errors}
|
|
|
|
| 814 |
except Exception as db_err:
|
| 815 |
logger.error(f"[{task_id}] Error determinando premium/grupo: {db_err}")
|
| 816 |
|
| 817 |
+
logger.info(f"[{task_id}] Subiendo stems a Hugging Face Dataset...")
|
| 818 |
+
self.update_state(state='PROGRESS', meta={'progress': 95, 'message': 'Subiendo stems a Hugging Face Dataset...'})
|
| 819 |
|
| 820 |
+
hf_urls = {}
|
| 821 |
+
from app.utils.hf_storage import upload_to_hf
|
| 822 |
|
| 823 |
for stem_name, stem_path, ext in stems_to_upload:
|
| 824 |
try:
|
|
|
|
| 826 |
raise TaskCancelledException("Tarea cancelada por el usuario.")
|
| 827 |
|
| 828 |
object_name = f"stems/{task_id}/{stem_name}.{ext}"
|
| 829 |
+
public_url = upload_to_hf(stem_path, object_name)
|
| 830 |
+
hf_urls[stem_name] = public_url
|
| 831 |
|
| 832 |
+
# Eliminar de disco local de inmediato tras subir
|
| 833 |
if os.path.exists(stem_path):
|
| 834 |
os.remove(stem_path)
|
| 835 |
except Exception as stem_err:
|
| 836 |
+
logger.warning(f"[{task_id}] Fallo al subir '{stem_name}' a Hugging Face: {stem_err}")
|
| 837 |
if isinstance(stem_err, TaskCancelledException):
|
| 838 |
raise stem_err
|
| 839 |
|
| 840 |
+
# Guardar URLs y configurar expiración
|
| 841 |
+
if hf_urls:
|
| 842 |
try:
|
| 843 |
from datetime import timedelta
|
| 844 |
db = SessionLocal()
|
| 845 |
task_record = db.query(TaskModel).filter(TaskModel.task_id == task_id).first()
|
| 846 |
if task_record:
|
| 847 |
+
task_record.telegram_file_ids = json.dumps(hf_urls)
|
| 848 |
if acordes:
|
| 849 |
task_record.chords = json.dumps(acordes)
|
| 850 |
task_record.status = "completed"
|
| 851 |
+
|
| 852 |
+
# Política de retención: Permanente si es Premium o pertenece a un grupo, 30 días si es normal
|
| 853 |
+
is_group_task = bool(task_record.group_ids)
|
| 854 |
+
if is_premium or is_group_task:
|
| 855 |
+
task_record.cloud_expires_at = None
|
| 856 |
+
logger.info(f"[{task_id}] cloud_expires_at = None (Permanente, premium={is_premium}, grupo={is_group_task})")
|
| 857 |
+
else:
|
| 858 |
+
task_record.cloud_expires_at = datetime.utcnow() + timedelta(days=30)
|
| 859 |
+
logger.info(f"[{task_id}] cloud_expires_at = {task_record.cloud_expires_at} (30 días, normal)")
|
| 860 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 861 |
db.commit()
|
| 862 |
db.close()
|
|
|
|
| 863 |
except Exception as db_err:
|
| 864 |
+
logger.error(f"[{task_id}] Error al registrar en DB: {db_err}")
|
| 865 |
|
| 866 |
# Limpiar la carpeta física local output_base de inmediato para liberar espacio en el disco del worker
|
| 867 |
try:
|
|
|
|
| 972 |
try:
|
| 973 |
from app.database import SessionLocal
|
| 974 |
from app.models.process import Task as TaskModel
|
|
|
|
| 975 |
from app.utils.redis_client import get_redis_client
|
| 976 |
from app.utils.harmony import generar_armonias
|
| 977 |
+
from app.utils.hf_storage import upload_to_hf, get_hf_download_url
|
| 978 |
import json
|
| 979 |
import os
|
| 980 |
import shutil
|
|
|
|
| 1008 |
if os.path.exists(flac_vocal):
|
| 1009 |
vocals_path = flac_vocal
|
| 1010 |
|
| 1011 |
+
# Si vocals no está localmente, descargarlo de Hugging Face
|
| 1012 |
if not os.path.exists(vocals_path):
|
| 1013 |
+
logger.info(f"[{task_id}] vocals no se encuentra localmente. Descargándolo de Hugging Face...")
|
| 1014 |
try:
|
| 1015 |
db = SessionLocal()
|
| 1016 |
task_record = db.query(TaskModel).filter(TaskModel.task_id == task_id).first()
|
|
|
|
| 1029 |
ext = "m4a" if ".m4a" in vocals_url.lower() else "flac"
|
| 1030 |
vocals_path = os.path.join(song_dir, f"vocals.{ext}")
|
| 1031 |
|
| 1032 |
+
# Generar URL de descarga de Hugging Face
|
| 1033 |
object_key = f"stems/{task_id}/vocals.{ext}"
|
| 1034 |
+
download_url = get_hf_download_url(object_key)
|
| 1035 |
|
| 1036 |
# Descargar archivo
|
| 1037 |
r = requests.get(download_url, stream=True)
|
|
|
|
| 1039 |
with open(vocals_path, 'wb') as f:
|
| 1040 |
for chunk in r.iter_content(chunk_size=8192):
|
| 1041 |
f.write(chunk)
|
| 1042 |
+
logger.info(f"[{task_id}] vocals.{ext} descargado con éxito de Hugging Face.")
|
| 1043 |
except Exception as dl_err:
|
| 1044 |
+
logger.error(f"[{task_id}] Error descargando vocals de Hugging Face para armonías: {dl_err}")
|
| 1045 |
if redis_client:
|
| 1046 |
redis_client.set(f"harmony_status:{task_id}", "error", ex=3600)
|
| 1047 |
return False
|
|
|
|
| 1051 |
if not res or not res.get("success"):
|
| 1052 |
raise Exception(res.get("error", "Error desconocido al armonizar."))
|
| 1053 |
|
| 1054 |
+
logger.info(f"[{task_id}] Armonías y trayectorias generadas localmente. Subiendo a Hugging Face...")
|
| 1055 |
|
| 1056 |
+
# 3. Subir a Hugging Face los 6 stems de coro y los 7 pitch JSON
|
| 1057 |
voces_corales = [
|
| 1058 |
"2da_voz", "2da_voz_baja", "3ra_voz",
|
| 1059 |
"3ra_voz_baja", "oct_alta", "oct_baja"
|
|
|
|
| 1074 |
|
| 1075 |
if os.path.exists(file_path):
|
| 1076 |
object_key = f"stems/{task_id}/{v}.{ext}"
|
| 1077 |
+
public_url = upload_to_hf(file_path, object_key)
|
| 1078 |
uploaded_files[v] = public_url
|
| 1079 |
|
| 1080 |
# Subir jsons
|
|
|
|
| 1082 |
file_path = os.path.join(song_dir, pj)
|
| 1083 |
if os.path.exists(file_path):
|
| 1084 |
object_key = f"stems/{task_id}/{pj}"
|
| 1085 |
+
public_url = upload_to_hf(file_path, object_key)
|
| 1086 |
pitch_key = pj.replace(".json", "")
|
| 1087 |
uploaded_files[pitch_key] = public_url
|
| 1088 |
|
|
|
|
| 1102 |
current_urls.update(uploaded_files)
|
| 1103 |
task_record.telegram_file_ids = json.dumps(current_urls)
|
| 1104 |
|
| 1105 |
+
# Si cloud_expires_at no está seteado aún, calcularlo ahora basado en el usuario/grupo
|
|
|
|
| 1106 |
if task_record.cloud_expires_at is None:
|
| 1107 |
try:
|
| 1108 |
user = db.query(UserModel).filter(UserModel.id == task_record.user_id).first()
|
| 1109 |
+
is_premium = user.is_premium if user else False
|
| 1110 |
+
is_group_task = bool(task_record.group_id or task_record.groups_shared)
|
| 1111 |
+
if is_premium or is_group_task:
|
| 1112 |
+
task_record.cloud_expires_at = None
|
| 1113 |
+
logger.info(f"[{task_id}] cloud_expires_at = None (Premium/Grupo)")
|
| 1114 |
+
else:
|
| 1115 |
+
task_record.cloud_expires_at = datetime.utcnow() + timedelta(days=30)
|
| 1116 |
+
logger.info(f"[{task_id}] cloud_expires_at seteada a 30 días para usuario normal: {task_record.cloud_expires_at}")
|
| 1117 |
except Exception as exp_err:
|
| 1118 |
logger.warning(f"[{task_id}] No se pudo calcular cloud_expires_at en armonías: {exp_err}")
|
| 1119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1120 |
db.commit()
|
| 1121 |
+
logger.info(f"[{task_id}] Registro de base de datos actualizado con URLs corales en Hugging Face.")
|
| 1122 |
db.close()
|
| 1123 |
|
| 1124 |
# 5. Limpieza de archivos generados localmente
|
|
|
|
| 1162 |
@celery_app.task(name="limpiar_stems_expirados_task")
|
| 1163 |
def limpiar_stems_expirados_task():
|
| 1164 |
"""
|
| 1165 |
+
Elimina de Hugging Face Dataset todos los stems y voces corales cuyo cloud_expires_at ya venció.
|
| 1166 |
+
También el 1 de cada mes purga los archivos de los usuarios con cuentas normales.
|
| 1167 |
+
Limpia el registro en DB (telegram_file_ids -> NULL, cloud_expires_at -> NULL).
|
|
|
|
|
|
|
| 1168 |
"""
|
| 1169 |
from app.database import SessionLocal
|
| 1170 |
from app.models.process import Task as TaskModel
|
| 1171 |
+
from app.utils.hf_storage import delete_folder_from_hf
|
|
|
|
| 1172 |
from datetime import datetime
|
| 1173 |
|
| 1174 |
+
logger.info("[Cleanup] Iniciando limpieza de stems expirados en Hugging Face...")
|
| 1175 |
db = SessionLocal()
|
| 1176 |
cleaned = 0
|
| 1177 |
errors = 0
|
| 1178 |
|
| 1179 |
try:
|
| 1180 |
now = datetime.utcnow()
|
| 1181 |
+
# 1. Tareas expiradas de forma normal
|
| 1182 |
expired_tasks = db.query(TaskModel).filter(
|
| 1183 |
TaskModel.cloud_expires_at <= now,
|
| 1184 |
+
TaskModel.telegram_file_ids != None
|
| 1185 |
).all()
|
| 1186 |
|
| 1187 |
logger.info(f"[Cleanup] {len(expired_tasks)} tasks expiradas encontradas.")
|
|
|
|
| 1189 |
for task in expired_tasks:
|
| 1190 |
try:
|
| 1191 |
task_id = task.task_id
|
| 1192 |
+
logger.info(f"[Cleanup] Eliminando carpeta en HF para task {task_id} (expiró: {task.cloud_expires_at})")
|
| 1193 |
|
| 1194 |
+
# Eliminar de Hugging Face
|
| 1195 |
+
delete_folder_from_hf(f"stems/{task_id}")
|
| 1196 |
|
| 1197 |
+
# Limpiar campos en DB
|
| 1198 |
task.telegram_file_ids = None
|
| 1199 |
task.cloud_expires_at = None
|
| 1200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1201 |
cleaned += 1
|
| 1202 |
+
logger.info(f"[Cleanup] Task {task_id} limpia con éxito.")
|
| 1203 |
except Exception as e:
|
| 1204 |
errors += 1
|
| 1205 |
logger.error(f"[Cleanup] Error limpiando task {task.task_id}: {e}")
|
| 1206 |
|
| 1207 |
+
# 2. Si es 1 de mes, purga de usuarios con cuentas normales
|
| 1208 |
+
if datetime.utcnow().day == 1:
|
| 1209 |
+
logger.info("[Cleanup] Es el 1 de mes. Iniciando purga de archivos de usuarios normales...")
|
| 1210 |
+
from app.models.user import User as UserModel
|
| 1211 |
+
try:
|
| 1212 |
+
normal_tasks = db.query(TaskModel).join(
|
| 1213 |
+
UserModel, TaskModel.user_id == UserModel.id
|
| 1214 |
+
).filter(
|
| 1215 |
+
UserModel.is_premium == False,
|
| 1216 |
+
TaskModel.group_id == None,
|
| 1217 |
+
TaskModel.telegram_file_ids != None
|
| 1218 |
+
).all()
|
| 1219 |
+
|
| 1220 |
+
# Filtrar que no estén en grupos compartidos
|
| 1221 |
+
normal_tasks = [t for t in normal_tasks if not t.groups_shared]
|
| 1222 |
+
|
| 1223 |
+
logger.info(f"[Cleanup] Encontradas {len(normal_tasks)} tareas de usuarios normales para purga mensual.")
|
| 1224 |
+
for task in normal_tasks:
|
| 1225 |
+
try:
|
| 1226 |
+
task_id = task.task_id
|
| 1227 |
+
logger.info(f"[Cleanup-Mensual] Eliminando stems en HF para tarea normal {task_id}")
|
| 1228 |
+
delete_folder_from_hf(f"stems/{task_id}")
|
| 1229 |
+
task.telegram_file_ids = None
|
| 1230 |
+
task.cloud_expires_at = None
|
| 1231 |
+
cleaned += 1
|
| 1232 |
+
except Exception as e:
|
| 1233 |
+
errors += 1
|
| 1234 |
+
logger.error(f"[Cleanup-Mensual] Error purgando tarea {task.task_id}: {e}")
|
| 1235 |
+
except Exception as monthly_err:
|
| 1236 |
+
logger.error(f"[Cleanup] Error en la purga mensual de usuarios normales: {monthly_err}")
|
| 1237 |
+
|
| 1238 |
db.commit()
|
| 1239 |
logger.info(f"[Cleanup] Finalizado. Limpiadas: {cleaned}, Errores: {errors}")
|
| 1240 |
return {"cleaned": cleaned, "errors": errors}
|
tests/test_b2_sync_flow.py
DELETED
|
@@ -1,310 +0,0 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
import os
|
| 3 |
-
import unittest
|
| 4 |
-
import json
|
| 5 |
-
from unittest.mock import patch, MagicMock
|
| 6 |
-
|
| 7 |
-
# Force environment variables for test
|
| 8 |
-
os.environ["SUPABASE_JWT_SECRET"] = "supersecretkey_change_me_in_production"
|
| 9 |
-
os.environ["SECRET_KEY"] = "supersecretkey_change_me_in_production"
|
| 10 |
-
os.environ["REDIS_URL"] = "rpc://"
|
| 11 |
-
|
| 12 |
-
# Mock heavy modules
|
| 13 |
-
sys.modules['librosa'] = MagicMock()
|
| 14 |
-
sys.modules['torch'] = MagicMock()
|
| 15 |
-
sys.modules['yt_dlp'] = MagicMock()
|
| 16 |
-
sys.modules['pretty_midi'] = MagicMock()
|
| 17 |
-
sys.modules['numpy'] = MagicMock()
|
| 18 |
-
sys.modules['demucs'] = MagicMock()
|
| 19 |
-
sys.modules['demucs.separate'] = MagicMock()
|
| 20 |
-
|
| 21 |
-
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 22 |
-
|
| 23 |
-
from fastapi.testclient import TestClient
|
| 24 |
-
from sqlalchemy import create_engine
|
| 25 |
-
from sqlalchemy.orm import sessionmaker
|
| 26 |
-
|
| 27 |
-
from app.database import Base, get_db
|
| 28 |
-
from app.models.user import User, Device
|
| 29 |
-
from app.models.lyrics import Group, UserGroup
|
| 30 |
-
from app.models.process import Task
|
| 31 |
-
from app.models.sync import B2SyncQueue
|
| 32 |
-
from main import app
|
| 33 |
-
|
| 34 |
-
# Configuración de base de datos de test
|
| 35 |
-
TEST_DATABASE_URL = "sqlite:///./test_b2_sync.db"
|
| 36 |
-
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
|
| 37 |
-
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 38 |
-
|
| 39 |
-
def override_get_db():
|
| 40 |
-
db = TestingSessionLocal()
|
| 41 |
-
try:
|
| 42 |
-
yield db
|
| 43 |
-
finally:
|
| 44 |
-
db.close()
|
| 45 |
-
|
| 46 |
-
app.dependency_overrides[get_db] = override_get_db
|
| 47 |
-
|
| 48 |
-
class TestB2SyncFlow(unittest.TestCase):
|
| 49 |
-
@classmethod
|
| 50 |
-
def setUpClass(cls):
|
| 51 |
-
Base.metadata.create_all(bind=engine)
|
| 52 |
-
cls.client = TestClient(app)
|
| 53 |
-
|
| 54 |
-
@classmethod
|
| 55 |
-
def tearDownClass(cls):
|
| 56 |
-
Base.metadata.drop_all(bind=engine)
|
| 57 |
-
if os.path.exists("./test_b2_sync.db"):
|
| 58 |
-
try:
|
| 59 |
-
os.remove("./test_b2_sync.db")
|
| 60 |
-
except Exception:
|
| 61 |
-
pass
|
| 62 |
-
|
| 63 |
-
def setUp(self):
|
| 64 |
-
db = TestingSessionLocal()
|
| 65 |
-
db.query(Task).delete()
|
| 66 |
-
db.query(Device).delete()
|
| 67 |
-
db.query(UserGroup).delete()
|
| 68 |
-
db.query(Group).delete()
|
| 69 |
-
db.query(User).delete()
|
| 70 |
-
db.query(B2SyncQueue).delete()
|
| 71 |
-
db.commit()
|
| 72 |
-
db.close()
|
| 73 |
-
|
| 74 |
-
@patch("app.utils.b2_storage.get_b2_download_url")
|
| 75 |
-
@patch("urllib.request.urlopen")
|
| 76 |
-
@patch("tasks.eliminar_archivos_locales_task.delay")
|
| 77 |
-
@patch("app.utils.b2_storage.delete_from_b2")
|
| 78 |
-
def test_standard_user_local_cleanup(self, mock_delete_from_b2, mock_eliminar_locales_delay, mock_urlopen, mock_get_b2_download_url):
|
| 79 |
-
mock_get_b2_download_url.side_effect = lambda obj_key: f"https://my-b2-bucket.com/{obj_key}"
|
| 80 |
-
# 1. Registrar usuario estándar
|
| 81 |
-
reg_payload = {"username": "standard_user", "email": "std@example.com", "password": "pass123"}
|
| 82 |
-
self.client.post("/auth/register", json=reg_payload)
|
| 83 |
-
|
| 84 |
-
token_res = self.client.post("/auth/token", data={
|
| 85 |
-
"username": "std@example.com", "password": "pass123",
|
| 86 |
-
"device_id": "std_device_1", "device_name": "Standard Device 1"
|
| 87 |
-
})
|
| 88 |
-
token = token_res.json()["access_token"]
|
| 89 |
-
headers = {"Authorization": f"Bearer {token}"}
|
| 90 |
-
|
| 91 |
-
# Crear una tarea con rutas de stems B2 (Caso Estándar B2)
|
| 92 |
-
db = TestingSessionLocal()
|
| 93 |
-
user = db.query(User).filter(User.username == "standard_user").first()
|
| 94 |
-
task_id = "test-task-std-123"
|
| 95 |
-
task = Task(
|
| 96 |
-
task_id=task_id,
|
| 97 |
-
user_id=user.id,
|
| 98 |
-
song_name="Standard Song",
|
| 99 |
-
status="completed",
|
| 100 |
-
telegram_file_ids=json.dumps({
|
| 101 |
-
"vocals": "https://my-b2-bucket.com/stems/test-task-std-123/vocals.flac",
|
| 102 |
-
"drums": "https://my-b2-bucket.com/stems/test-task-std-123/drums.flac"
|
| 103 |
-
})
|
| 104 |
-
)
|
| 105 |
-
sync_queue = B2SyncQueue(
|
| 106 |
-
id=task_id,
|
| 107 |
-
file_key=f"stems/{task_id}",
|
| 108 |
-
file_url=json.dumps({
|
| 109 |
-
"vocals": "https://my-b2-bucket.com/stems/test-task-std-123/vocals.flac",
|
| 110 |
-
"drums": "https://my-b2-bucket.com/stems/test-task-std-123/drums.flac"
|
| 111 |
-
}),
|
| 112 |
-
sync_type="device",
|
| 113 |
-
target_id=str(user.id),
|
| 114 |
-
pending_recipients=["std_device_1"]
|
| 115 |
-
)
|
| 116 |
-
db.add(task)
|
| 117 |
-
db.add(sync_queue)
|
| 118 |
-
db.commit()
|
| 119 |
-
db.close()
|
| 120 |
-
|
| 121 |
-
# 2. Consultar get_stems y verificar que retorna las URLs directas de B2 (no usa el Gist tunnel URL)
|
| 122 |
-
stems_res = self.client.get(f"/songs/stems/{task_id}", headers=headers)
|
| 123 |
-
self.assertEqual(stems_res.status_code, 200)
|
| 124 |
-
stems_data = stems_res.json()
|
| 125 |
-
self.assertEqual(stems_data["stems"]["vocals"], "https://my-b2-bucket.com/stems/test-task-std-123/vocals.flac")
|
| 126 |
-
self.assertEqual(stems_data["stems"]["drums"], "https://my-b2-bucket.com/stems/test-task-std-123/drums.flac")
|
| 127 |
-
mock_urlopen.assert_not_called()
|
| 128 |
-
|
| 129 |
-
# 3. Confirmar descarga y verificar que se eliminan los stems de B2 de inmediato y se gatilla Celery local deletion
|
| 130 |
-
confirm_res = self.client.post(f"/songs/confirm-download/{task_id}?device_id=std_device_1", headers=headers)
|
| 131 |
-
self.assertEqual(confirm_res.status_code, 200)
|
| 132 |
-
|
| 133 |
-
self.assertEqual(mock_delete_from_b2.call_count, 2)
|
| 134 |
-
mock_delete_from_b2.assert_any_call("stems/test-task-std-123/vocals.flac")
|
| 135 |
-
mock_delete_from_b2.assert_any_call("stems/test-task-std-123/drums.flac")
|
| 136 |
-
|
| 137 |
-
mock_eliminar_locales_delay.assert_called_once_with(task_id)
|
| 138 |
-
|
| 139 |
-
# Verificar que downloaded_at está seteado
|
| 140 |
-
db = TestingSessionLocal()
|
| 141 |
-
task_after = db.query(Task).filter(Task.task_id == task_id).first()
|
| 142 |
-
self.assertIsNotNone(task_after.downloaded_at)
|
| 143 |
-
|
| 144 |
-
# Verificar que la cola de sincronización B2SyncQueue se eliminó
|
| 145 |
-
self.assertIsNone(db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first())
|
| 146 |
-
db.close()
|
| 147 |
-
|
| 148 |
-
@patch("app.utils.b2_storage.delete_from_b2")
|
| 149 |
-
def test_premium_user_b2_sync_flow(self, mock_delete_from_b2):
|
| 150 |
-
# 1. Registrar usuario premium
|
| 151 |
-
reg_payload = {"username": "premium_user", "email": "prem@example.com", "password": "pass123"}
|
| 152 |
-
self.client.post("/auth/register", json=reg_payload)
|
| 153 |
-
|
| 154 |
-
# Iniciar sesión en un dispositivo para registrar dev_A
|
| 155 |
-
t1_res = self.client.post("/auth/token", data={
|
| 156 |
-
"username": "prem@example.com", "password": "pass123",
|
| 157 |
-
"device_id": "dev_A", "device_name": "Device A"
|
| 158 |
-
})
|
| 159 |
-
token_A = t1_res.json()["access_token"]
|
| 160 |
-
headers_A = {"Authorization": f"Bearer {token_A}"}
|
| 161 |
-
|
| 162 |
-
# Seteamos premium a True y obtenemos ID
|
| 163 |
-
db = TestingSessionLocal()
|
| 164 |
-
user = db.query(User).filter(User.username == "premium_user").first()
|
| 165 |
-
user.is_premium = True
|
| 166 |
-
db.commit()
|
| 167 |
-
user_id = user.id
|
| 168 |
-
|
| 169 |
-
dev_b = Device(device_id="dev_B", name="Device B", user_id=user_id)
|
| 170 |
-
db.add(dev_b)
|
| 171 |
-
db.commit()
|
| 172 |
-
db.close()
|
| 173 |
-
|
| 174 |
-
task_id = "test-task-prem-456"
|
| 175 |
-
|
| 176 |
-
# 2. Crear cola de sincronización B2SyncQueue simulada para premium (dos dispositivos pendientes)
|
| 177 |
-
db = TestingSessionLocal()
|
| 178 |
-
sync_queue = B2SyncQueue(
|
| 179 |
-
id=task_id,
|
| 180 |
-
file_key=f"stems/{task_id}",
|
| 181 |
-
file_url=json.dumps({
|
| 182 |
-
"vocals": "https://mock-b2.com/vocals.flac",
|
| 183 |
-
"drums": "https://mock-b2.com/drums.flac"
|
| 184 |
-
}),
|
| 185 |
-
sync_type="device",
|
| 186 |
-
target_id=str(user_id),
|
| 187 |
-
pending_recipients=["dev_A", "dev_B"]
|
| 188 |
-
)
|
| 189 |
-
task = Task(
|
| 190 |
-
task_id=task_id,
|
| 191 |
-
user_id=user_id,
|
| 192 |
-
song_name="Premium Song",
|
| 193 |
-
status="completed",
|
| 194 |
-
telegram_file_ids=json.dumps({
|
| 195 |
-
"vocals": "https://mock-b2.com/vocals.flac",
|
| 196 |
-
"drums": "https://mock-b2.com/drums.flac"
|
| 197 |
-
})
|
| 198 |
-
)
|
| 199 |
-
db.add(sync_queue)
|
| 200 |
-
db.add(task)
|
| 201 |
-
db.commit()
|
| 202 |
-
db.close()
|
| 203 |
-
|
| 204 |
-
# 3. Confirmar descarga de dev_A
|
| 205 |
-
confirm_A = self.client.post(f"/songs/confirm-download/{task_id}?device_id=dev_A", headers=headers_A)
|
| 206 |
-
self.assertEqual(confirm_A.status_code, 200)
|
| 207 |
-
self.assertEqual(confirm_A.json()["pending_recipients"], ["dev_B"])
|
| 208 |
-
mock_delete_from_b2.assert_not_called()
|
| 209 |
-
|
| 210 |
-
# Verificar que el registro aún existe
|
| 211 |
-
db = TestingSessionLocal()
|
| 212 |
-
self.assertIsNotNone(db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first())
|
| 213 |
-
db.close()
|
| 214 |
-
|
| 215 |
-
# 4. Confirmar descarga de dev_B
|
| 216 |
-
confirm_B = self.client.post(f"/songs/confirm-download/{task_id}?device_id=dev_B", headers=headers_A)
|
| 217 |
-
self.assertEqual(confirm_B.status_code, 200)
|
| 218 |
-
self.assertEqual(confirm_B.json()["pending_recipients"], [])
|
| 219 |
-
|
| 220 |
-
# Verificar que se llamó delete_from_b2 para cada stem
|
| 221 |
-
self.assertEqual(mock_delete_from_b2.call_count, 2)
|
| 222 |
-
mock_delete_from_b2.assert_any_call("stems/test-task-prem-456/vocals.flac")
|
| 223 |
-
mock_delete_from_b2.assert_any_call("stems/test-task-prem-456/drums.flac")
|
| 224 |
-
|
| 225 |
-
# Verificar que el registro en B2SyncQueue se eliminó
|
| 226 |
-
db = TestingSessionLocal()
|
| 227 |
-
self.assertIsNone(db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first())
|
| 228 |
-
db.close()
|
| 229 |
-
|
| 230 |
-
@patch("app.utils.b2_storage.delete_from_b2")
|
| 231 |
-
def test_group_b2_sync_flow(self, mock_delete_from_b2):
|
| 232 |
-
# 1. Registrar dos usuarios del grupo
|
| 233 |
-
self.client.post("/auth/register", json={"username": "m1", "email": "m1@example.com", "password": "pass"})
|
| 234 |
-
self.client.post("/auth/register", json={"username": "m2", "email": "m2@example.com", "password": "pass"})
|
| 235 |
-
|
| 236 |
-
token1 = self.client.post("/auth/token", data={"username": "m1@example.com", "password": "pass", "device_id": "dev_1", "device_name": "Dev 1"}).json()["access_token"]
|
| 237 |
-
token2 = self.client.post("/auth/token", data={"username": "m2@example.com", "password": "pass", "device_id": "dev_2", "device_name": "Dev 2"}).json()["access_token"]
|
| 238 |
-
|
| 239 |
-
headers1 = {"Authorization": f"Bearer {token1}"}
|
| 240 |
-
headers2 = {"Authorization": f"Bearer {token2}"}
|
| 241 |
-
|
| 242 |
-
# Obtener IDs de usuarios
|
| 243 |
-
db = TestingSessionLocal()
|
| 244 |
-
u1 = db.query(User).filter(User.username == "m1").first()
|
| 245 |
-
u2 = db.query(User).filter(User.username == "m2").first()
|
| 246 |
-
u1_id = u1.id
|
| 247 |
-
u2_id = u2.id
|
| 248 |
-
|
| 249 |
-
# Crear grupo y asociar usuarios
|
| 250 |
-
group = Group(name="Coral Group")
|
| 251 |
-
db.add(group)
|
| 252 |
-
db.commit()
|
| 253 |
-
group_id = group.id
|
| 254 |
-
|
| 255 |
-
ug1 = UserGroup(user_id=u1_id, group_id=group_id, estado="activo", role="creador")
|
| 256 |
-
ug2 = UserGroup(user_id=u2_id, group_id=group_id, estado="activo", role="miembro")
|
| 257 |
-
db.add(ug1)
|
| 258 |
-
db.add(ug2)
|
| 259 |
-
db.commit()
|
| 260 |
-
db.close()
|
| 261 |
-
|
| 262 |
-
task_id = "test-task-group-789"
|
| 263 |
-
|
| 264 |
-
# Crear cola de sincronización B2SyncQueue para grupo
|
| 265 |
-
db = TestingSessionLocal()
|
| 266 |
-
sync_queue = B2SyncQueue(
|
| 267 |
-
id=task_id,
|
| 268 |
-
file_key=f"stems/{task_id}",
|
| 269 |
-
file_url=json.dumps({
|
| 270 |
-
"vocals": "https://mock-b2.com/vocals.flac"
|
| 271 |
-
}),
|
| 272 |
-
sync_type="group",
|
| 273 |
-
target_id=str(group_id),
|
| 274 |
-
pending_recipients=[str(u1_id), str(u2_id)]
|
| 275 |
-
)
|
| 276 |
-
task = Task(
|
| 277 |
-
task_id=task_id,
|
| 278 |
-
user_id=u1_id,
|
| 279 |
-
group_id=group_id,
|
| 280 |
-
song_name="Group Song",
|
| 281 |
-
status="completed",
|
| 282 |
-
telegram_file_ids=json.dumps({
|
| 283 |
-
"vocals": "https://mock-b2.com/vocals.flac"
|
| 284 |
-
})
|
| 285 |
-
)
|
| 286 |
-
db.add(sync_queue)
|
| 287 |
-
db.add(task)
|
| 288 |
-
db.commit()
|
| 289 |
-
db.close()
|
| 290 |
-
|
| 291 |
-
# 2. Confirmar descarga de Miembro 1 (creador)
|
| 292 |
-
confirm_1 = self.client.post(f"/songs/confirm-download/{task_id}", headers=headers1)
|
| 293 |
-
self.assertEqual(confirm_1.status_code, 200)
|
| 294 |
-
self.assertEqual(confirm_1.json()["pending_recipients"], [str(u2_id)])
|
| 295 |
-
mock_delete_from_b2.assert_not_called()
|
| 296 |
-
|
| 297 |
-
# 3. Confirmar descarga de Miembro 2 (miembro)
|
| 298 |
-
confirm_2 = self.client.post(f"/songs/confirm-download/{task_id}", headers=headers2)
|
| 299 |
-
self.assertEqual(confirm_2.status_code, 200)
|
| 300 |
-
self.assertEqual(confirm_2.json()["pending_recipients"], [])
|
| 301 |
-
|
| 302 |
-
mock_delete_from_b2.assert_called_once_with("stems/test-task-group-789/vocals.flac")
|
| 303 |
-
|
| 304 |
-
# Verificar eliminación de cola
|
| 305 |
-
db = TestingSessionLocal()
|
| 306 |
-
self.assertIsNone(db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first())
|
| 307 |
-
db.close()
|
| 308 |
-
|
| 309 |
-
if __name__ == "__main__":
|
| 310 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_p2p_sync_flow.py
DELETED
|
@@ -1,222 +0,0 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
import os
|
| 3 |
-
import unittest
|
| 4 |
-
import json
|
| 5 |
-
from unittest.mock import patch, MagicMock
|
| 6 |
-
|
| 7 |
-
# Force environment variables for test
|
| 8 |
-
os.environ["SUPABASE_JWT_SECRET"] = "supersecretkey_change_me_in_production"
|
| 9 |
-
os.environ["SECRET_KEY"] = "supersecretkey_change_me_in_production"
|
| 10 |
-
os.environ["REDIS_URL"] = "rpc://"
|
| 11 |
-
|
| 12 |
-
# Mock heavy modules
|
| 13 |
-
sys.modules['librosa'] = MagicMock()
|
| 14 |
-
sys.modules['torch'] = MagicMock()
|
| 15 |
-
sys.modules['yt_dlp'] = MagicMock()
|
| 16 |
-
sys.modules['pretty_midi'] = MagicMock()
|
| 17 |
-
sys.modules['numpy'] = MagicMock()
|
| 18 |
-
sys.modules['demucs'] = MagicMock()
|
| 19 |
-
sys.modules['demucs.separate'] = MagicMock()
|
| 20 |
-
|
| 21 |
-
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 22 |
-
|
| 23 |
-
from fastapi.testclient import TestClient
|
| 24 |
-
from sqlalchemy import create_engine
|
| 25 |
-
from sqlalchemy.orm import sessionmaker
|
| 26 |
-
|
| 27 |
-
from app.database import Base, get_db
|
| 28 |
-
from app.models.user import User, Device
|
| 29 |
-
from app.models.lyrics import Group, UserGroup
|
| 30 |
-
from app.models.process import Task
|
| 31 |
-
from app.models.sync import B2SyncQueue
|
| 32 |
-
from main import app
|
| 33 |
-
|
| 34 |
-
# Configuración de base de datos de test
|
| 35 |
-
TEST_DATABASE_URL = "sqlite:///./test_p2p_sync.db"
|
| 36 |
-
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
|
| 37 |
-
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 38 |
-
|
| 39 |
-
def override_get_db():
|
| 40 |
-
db = TestingSessionLocal()
|
| 41 |
-
try:
|
| 42 |
-
yield db
|
| 43 |
-
finally:
|
| 44 |
-
db.close()
|
| 45 |
-
|
| 46 |
-
app.dependency_overrides[get_db] = override_get_db
|
| 47 |
-
|
| 48 |
-
class TestP2PSyncFlow(unittest.TestCase):
|
| 49 |
-
@classmethod
|
| 50 |
-
def setUpClass(cls):
|
| 51 |
-
Base.metadata.create_all(bind=engine)
|
| 52 |
-
cls.client = TestClient(app)
|
| 53 |
-
|
| 54 |
-
@classmethod
|
| 55 |
-
def tearDownClass(cls):
|
| 56 |
-
Base.metadata.drop_all(bind=engine)
|
| 57 |
-
if os.path.exists("./test_p2p_sync.db"):
|
| 58 |
-
try:
|
| 59 |
-
os.remove("./test_p2p_sync.db")
|
| 60 |
-
except Exception:
|
| 61 |
-
pass
|
| 62 |
-
|
| 63 |
-
def setUp(self):
|
| 64 |
-
db = TestingSessionLocal()
|
| 65 |
-
db.query(Task).delete()
|
| 66 |
-
db.query(Device).delete()
|
| 67 |
-
db.query(UserGroup).delete()
|
| 68 |
-
db.query(Group).delete()
|
| 69 |
-
db.query(User).delete()
|
| 70 |
-
db.query(B2SyncQueue).delete()
|
| 71 |
-
db.commit()
|
| 72 |
-
db.close()
|
| 73 |
-
|
| 74 |
-
@patch("app.utils.b2_storage.upload_to_b2")
|
| 75 |
-
@patch("app.utils.b2_storage.delete_from_b2")
|
| 76 |
-
def test_p2p_group_sync_workflow(self, mock_delete_from_b2, mock_upload_to_b2):
|
| 77 |
-
mock_upload_to_b2.return_value = "https://mock-b2.com/stems/test-task-123/vocals.flac"
|
| 78 |
-
|
| 79 |
-
# 1. Registrar dos usuarios (m1: miembro existente, m2: nuevo miembro)
|
| 80 |
-
self.client.post("/auth/register", json={"username": "m1", "email": "m1@example.com", "password": "pass"})
|
| 81 |
-
self.client.post("/auth/register", json={"username": "m2", "email": "m2@example.com", "password": "pass"})
|
| 82 |
-
|
| 83 |
-
token1 = self.client.post("/auth/token", data={"username": "m1@example.com", "password": "pass", "device_id": "dev_1", "device_name": "Dev 1"}).json()["access_token"]
|
| 84 |
-
token2 = self.client.post("/auth/token", data={"username": "m2@example.com", "password": "pass", "device_id": "dev_2", "device_name": "Dev 2"}).json()["access_token"]
|
| 85 |
-
|
| 86 |
-
headers1 = {"Authorization": f"Bearer {token1}"}
|
| 87 |
-
headers2 = {"Authorization": f"Bearer {token2}"}
|
| 88 |
-
|
| 89 |
-
db = TestingSessionLocal()
|
| 90 |
-
u1 = db.query(User).filter(User.username == "m1").first()
|
| 91 |
-
u2 = db.query(User).filter(User.username == "m2").first()
|
| 92 |
-
u1_id = u1.id
|
| 93 |
-
u2_id = u2.id
|
| 94 |
-
|
| 95 |
-
# Crear grupo y asociar
|
| 96 |
-
group = Group(name="Rock Band")
|
| 97 |
-
db.add(group)
|
| 98 |
-
db.commit()
|
| 99 |
-
group_id = group.id
|
| 100 |
-
|
| 101 |
-
ug1 = UserGroup(user_id=u1_id, group_id=group_id, estado="activo", role="creador")
|
| 102 |
-
ug2 = UserGroup(user_id=u2_id, group_id=group_id, estado="activo", role="miembro")
|
| 103 |
-
db.add(ug1)
|
| 104 |
-
db.add(ug2)
|
| 105 |
-
db.commit()
|
| 106 |
-
|
| 107 |
-
# Crear una tarea ya completada que pertenece al grupo
|
| 108 |
-
task_id = "test-group-task-777"
|
| 109 |
-
task = Task(
|
| 110 |
-
task_id=task_id,
|
| 111 |
-
user_id=u1_id,
|
| 112 |
-
group_id=group_id,
|
| 113 |
-
song_name="Awesome Guitar Song",
|
| 114 |
-
status="completed",
|
| 115 |
-
stems=4,
|
| 116 |
-
telegram_file_ids=json.dumps({
|
| 117 |
-
"vocals": "https://mock-b2.com/vocals.flac",
|
| 118 |
-
"drums": "https://mock-b2.com/drums.flac"
|
| 119 |
-
})
|
| 120 |
-
)
|
| 121 |
-
db.add(task)
|
| 122 |
-
db.commit()
|
| 123 |
-
db.close()
|
| 124 |
-
|
| 125 |
-
# 2. El nuevo miembro (m2) reporta que le faltan stems de esta canción
|
| 126 |
-
report_res = self.client.post(
|
| 127 |
-
"/songs/sync-requests/report-missing",
|
| 128 |
-
headers=headers2,
|
| 129 |
-
json={
|
| 130 |
-
"device_id": "dev_2",
|
| 131 |
-
"missing_items": [
|
| 132 |
-
{
|
| 133 |
-
"task_id": task_id,
|
| 134 |
-
"missing_stems": ["vocals", "drums"]
|
| 135 |
-
}
|
| 136 |
-
]
|
| 137 |
-
}
|
| 138 |
-
)
|
| 139 |
-
self.assertEqual(report_res.status_code, 200)
|
| 140 |
-
self.assertIn(task_id, report_res.json()["reported_tasks"])
|
| 141 |
-
|
| 142 |
-
# Verificar que el registro B2SyncQueue se creó con is_uploaded=False
|
| 143 |
-
db = TestingSessionLocal()
|
| 144 |
-
sq = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 145 |
-
self.assertIsNotNone(sq)
|
| 146 |
-
self.assertFalse(sq.is_uploaded)
|
| 147 |
-
self.assertEqual(sq.pending_recipients, ["dev_2"])
|
| 148 |
-
db.close()
|
| 149 |
-
|
| 150 |
-
# 3. El miembro existente (m1) consulta las tareas pendientes de sincronización
|
| 151 |
-
pending_res = self.client.get("/songs/sync-requests/pending", headers=headers1)
|
| 152 |
-
self.assertEqual(pending_res.status_code, 200)
|
| 153 |
-
pending_list = pending_res.json()
|
| 154 |
-
self.assertEqual(len(pending_list), 1)
|
| 155 |
-
self.assertEqual(pending_list[0]["task_id"], task_id)
|
| 156 |
-
self.assertEqual(set(pending_list[0]["required_stems"]), {"vocals", "drums"})
|
| 157 |
-
|
| 158 |
-
# 4. El miembro 1 adquiere el bloqueo de subida (claim lock)
|
| 159 |
-
claim_res = self.client.post(
|
| 160 |
-
f"/songs/sync-requests/claim-upload/{task_id}",
|
| 161 |
-
headers=headers1,
|
| 162 |
-
data={"device_id": "dev_1"}
|
| 163 |
-
)
|
| 164 |
-
self.assertEqual(claim_res.status_code, 200)
|
| 165 |
-
self.assertTrue(claim_res.json()["success"])
|
| 166 |
-
|
| 167 |
-
# 5. El miembro 1 sube los stems requeridos
|
| 168 |
-
# Simular envío de archivo vocals
|
| 169 |
-
vocals_file = ("vocals.flac", b"dummy_audio_bytes")
|
| 170 |
-
upload_vocals_res = self.client.post(
|
| 171 |
-
f"/songs/sync-requests/upload/{task_id}/vocals",
|
| 172 |
-
headers=headers1,
|
| 173 |
-
files={"file": vocals_file}
|
| 174 |
-
)
|
| 175 |
-
self.assertEqual(upload_vocals_res.status_code, 200)
|
| 176 |
-
self.assertTrue(upload_vocals_res.json()["success"])
|
| 177 |
-
|
| 178 |
-
# Simular envío de archivo drums
|
| 179 |
-
drums_file = ("drums.flac", b"dummy_audio_bytes")
|
| 180 |
-
upload_drums_res = self.client.post(
|
| 181 |
-
f"/songs/sync-requests/upload/{task_id}/drums",
|
| 182 |
-
headers=headers1,
|
| 183 |
-
files={"file": drums_file}
|
| 184 |
-
)
|
| 185 |
-
self.assertEqual(upload_drums_res.status_code, 200)
|
| 186 |
-
self.assertTrue(upload_drums_res.json()["success"])
|
| 187 |
-
|
| 188 |
-
# Completar la subida
|
| 189 |
-
complete_res = self.client.post(
|
| 190 |
-
f"/songs/sync-requests/complete-upload/{task_id}",
|
| 191 |
-
headers=headers1
|
| 192 |
-
)
|
| 193 |
-
self.assertEqual(complete_res.status_code, 200)
|
| 194 |
-
self.assertTrue(complete_res.json()["success"])
|
| 195 |
-
|
| 196 |
-
# 6. El nuevo miembro (m2) consulta su historial de canciones y ve el estado correcto
|
| 197 |
-
history_res = self.client.get("/songs/history", headers=headers2)
|
| 198 |
-
self.assertEqual(history_res.status_code, 200)
|
| 199 |
-
tasks_list = history_res.json()["tasks"]
|
| 200 |
-
self.assertEqual(len(tasks_list), 1)
|
| 201 |
-
self.assertEqual(tasks_list[0]["sync_status"], "pending_my_download")
|
| 202 |
-
self.assertEqual(set(tasks_list[0]["missing_stems_for_me"]), {"vocals", "drums"})
|
| 203 |
-
|
| 204 |
-
# 7. El nuevo miembro descarga los archivos y confirma descarga
|
| 205 |
-
confirm_res = self.client.post(
|
| 206 |
-
f"/songs/confirm-download/{task_id}",
|
| 207 |
-
headers=headers2
|
| 208 |
-
)
|
| 209 |
-
self.assertEqual(confirm_res.status_code, 200)
|
| 210 |
-
|
| 211 |
-
# 8. Verificar que la sincronización se completó (B2SyncQueue se eliminó y delete_from_b2 se gatilló)
|
| 212 |
-
db = TestingSessionLocal()
|
| 213 |
-
sq_after = db.query(B2SyncQueue).filter(B2SyncQueue.id == task_id).first()
|
| 214 |
-
self.assertIsNone(sq_after)
|
| 215 |
-
db.close()
|
| 216 |
-
|
| 217 |
-
self.assertEqual(mock_delete_from_b2.call_count, 2)
|
| 218 |
-
mock_delete_from_b2.assert_any_call("stems/test-group-task-777/vocals.flac")
|
| 219 |
-
mock_delete_from_b2.assert_any_call("stems/test-group-task-777/drums.flac")
|
| 220 |
-
|
| 221 |
-
if __name__ == "__main__":
|
| 222 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|