GitHub Action commited on
Commit
3cc84f5
·
1 Parent(s): bb3f929

deploy from github actions

Browse files
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
- # Remover lo que ese miembro compartió con este grupo
 
 
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 if g.id != grupo_id]
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
- Cuando se registra un nuevo dispositivo, busca todas las tareas del usuario
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
- try:
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.b2_storage import (
16
- upload_to_b2,
17
- delete_folder_from_b2,
18
- get_b2_download_url
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
- delete_folder_from_b2(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,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 B2 y BD.
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 B2 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_b2(f"migrations/{current_user.id}/")
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 B2.
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 Backblaze B2
146
  object_key = f"migrations/{current_user.id}/{task_id}/{stem_name}.{ext}"
147
- public_url = upload_to_b2(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,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 B2: {str(e)}")
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 firmadas de descarga de B2 para todos los stems respaldados.
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 firmadas de descarga de B2
241
  restore_files = []
242
  files_list = backup.files_info or []
243
 
244
  for f in files_list:
245
  file_key = f["file_key"]
246
- # Generar una URL firmada válida por 4 horas (14400s) para asegurar que le dé tiempo a descargar
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 B2.
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 B2
275
- logger.info(f"Limpiando carpeta de B2 tras restauración exitosa de {backup.id}")
276
- delete_folder_from_b2(f"migrations/{current_user.id}/")
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.b2_storage import get_b2_download_url
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 (backblazeb2.com, tebi.io, etc.)
990
  parts = file_url.split("stems/")
991
  if len(parts) > 1:
992
  object_key = "stems/" + parts[-1]
993
- presigned_urls[stem_name] = get_b2_download_url(object_key)
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 desde los dispositivos helpers.
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 para forzar resincronización.")
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
- Si está registrado en B2SyncQueue (Premium/Grupo), se remueve al destinatario.
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
- import json as _json
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
- if sync_entry:
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 y limpieza Supabase realizada.")
 
 
 
 
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": sync_entry.pending_recipients if sync_entry else []
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 del canal de Telegram (cuando el usuario borra la pista explícitamente)
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
- import json as _json
1328
- from app.utils.b2_storage import delete_from_b2
1329
- urls_dict = _json.loads(task_record.telegram_file_ids)
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 B2 al borrar pista {task_id}: {e}")
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 COLA DE SINCRONIZACIÓN B2
213
  # ==========================================
214
 
215
  def clean_expired_sync_queue(max_age_days=7, dry_run=False):
216
  """
217
- Elimina entradas de b2_sync_queue que tengan más de max_age_days días,
218
- y purga sus correspondientes archivos de Backblaze B2.
219
  """
220
- logger.info(f"Limpiando cola de sincronización B2SyncQueue más antigua de {max_age_days} días...")
 
 
 
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 app.database import SessionLocal
228
- from app.models.sync import B2SyncQueue
229
- from app.utils.b2_storage import delete_from_b2
230
- import json
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 en clean_expired_sync_queue: {e}")
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) # B2SyncQueue: X días
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"Cola sync limpia: {cleaned_sync}")
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 Backblaze B2...")
818
- self.update_state(state='PROGRESS', meta={'progress': 95, 'message': 'Subiendo stems a Backblaze B2...'})
819
 
820
- b2_urls = {}
821
- from app.utils.b2_storage import upload_to_b2
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 = upload_to_b2(stem_path, object_name)
830
- b2_urls[stem_name] = public_url
831
 
832
- # Eliminar de disco local de inmediato tras subir a B2
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 B2: {stem_err}")
837
  if isinstance(stem_err, TaskCancelledException):
838
  raise stem_err
839
 
840
- # Guardar URLs y crear cola de sincronización B2SyncQueue
841
- if b2_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(b2_urls)
848
  if acordes:
849
  task_record.chords = json.dumps(acordes)
850
  task_record.status = "completed"
851
- # Calcular expiración en Tebi: 7 días premium, 3 días normal
852
- retention_days = 7 if is_premium else 3
853
- task_record.cloud_expires_at = datetime.utcnow() + timedelta(days=retention_days)
854
- logger.info(f"[{task_id}] cloud_expires_at = {task_record.cloud_expires_at} ({retention_days}d, premium={is_premium})")
855
-
856
- # Rellenar destinatarios pendientes
857
- pending_recipients = []
858
- sync_type = "device"
859
- target_id = ""
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 B2SyncQueue: {db_err}")
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.b2_storage import upload_to_b2, get_b2_download_url
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 B2
1041
  if not os.path.exists(vocals_path):
1042
- logger.info(f"[{task_id}] vocals no se encuentra localmente. Descargándolo de B2...")
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 firmada de B2
1062
  object_key = f"stems/{task_id}/vocals.{ext}"
1063
- download_url = get_b2_download_url(object_key)
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 B2.")
1072
  except Exception as dl_err:
1073
- logger.error(f"[{task_id}] Error descargando vocals de B2 para armonías: {dl_err}")
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 B2...")
1084
 
1085
- # 3. Subir a B2 los 6 stems de coro (m4a o flac) y los 7 pitch JSON
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 = upload_to_b2(file_path, object_key)
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 = upload_to_b2(file_path, object_key)
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 (tarea antigua o raro edge case),
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
- retention_days = 7 if (user and user.is_premium) else 3
1140
- task_record.cloud_expires_at = datetime.utcnow() + timedelta(days=retention_days)
1141
- logger.info(f"[{task_id}] cloud_expires_at seteada por armonías: {task_record.cloud_expires_at}")
 
 
 
 
 
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 Tebi (B2) todos los stems y voces corales cuyo cloud_expires_at ya venció.
1196
- Limpia también el registro en DB (telegram_file_ids NULL, B2SyncQueue eliminado).
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.models.sync import B2SyncQueue as B2SyncQueueModel
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 Tebi...")
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 # noqa: E711
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 Tebi para task {task_id} (expiró: {task.cloud_expires_at})")
1225
 
1226
- # 1. Eliminar carpeta completa de Tebi
1227
- delete_folder_from_b2(f"stems/{task_id}/")
1228
 
1229
- # 2. Limpiar campos en Task
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} limpiada con éxito.")
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()