Recalcular duração ao trocar o arquivo de áudio

TLDR: Ao substituir o media_file de um áudio (ou meditação) já existente, recalcular duration/file_size e atualizar o total_duration dos progressos daquele conteúdo, corrigindo barras de progresso e percentuais inconsistentes.

Contexto

Ao editar um áudio diário no admin e trocar o arquivo, a duração não é recalculada — os progressos passam a usar uma duração que não condiz com o áudio novo, gerando barras e percentuais incoerentes.

Causa raiz — MediaMetadataMixin.save() (apps/audios/models/media_base.py:57-64) só calcula file_size/duration quando not self.file_size:

python def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.media_file and not self.file_size: self.file_size = self.media_file.size file_obj = default_storage.open(self.media_file.name) audio = MP3(file_obj) self.duration = audio.info.length super().save(update_fields=["file_size", "duration"])

Num registro existente o file_size já tem valor, então a condição é falsa: o arquivo é trocado mas duration continua a antiga.

Consequência — AudioProgress.total_duration (e MeditationProgress.total_duration) é um snapshot pego só na criação (apps/engagements/models/progress.py:62-65 e 89-92). Os progressos existentes ficam com a duração antiga. O front calcula a barra como position / total_duration e o admin usa total_duration para métricas (apps/audios/admin.py:44-47), então a troca de arquivo deixa esses valores inconsistentes (ex.: position > total_duration, ou percentual irreal).

A detecção de conclusão em runtime (apply_progress_update, progress_helpers.py:47) já usa audio.duration ao vivo — então novos heartbeats ficam corretos assim que a duração for recalculada; o problema é só a duração persistida e o snapshot dos progressos.

Audio e Meditation compartilham MediaMetadataMixin + EngagementMetricsMixin e ambos expõem progress_model_label/progress_field_name, permitindo reconciliar o progresso de forma genérica no mixin.

Objetivos

  • Ao trocar o media_file de um áudio/meditação existente, recalcular duration e file_size
  • Ao recalcular a duração, atualizar o total_duration de todos os progressos daquele conteúdo
  • Não alterar position nem completed_at dos progressos (decisão do dono — só sincronizar total_duration)
  • Aplicar de forma síncrona dentro do save(), cobrindo qualquer fluxo de edição (admin inclusive)
  • Cobrir áudio e meditação, já que a causa raiz e o model de progresso são idênticos
  • Não regredir a criação (o primeiro upload continua calculando como hoje)

Fora de escopo

AudioProgress.save() e MeditationProgress.save() chamam super().save() dentro do if (diferente de LessonProgress). Logo, um .save() num progresso com total_duration já preenchido não persiste. O fix usa .update(), então não é afetado — o bug latente fica registrado para tratar à parte.

Mudanças

Recalcular ao detectar troca de arquivo

MediaMetadataMixin.save() passa a recalcular quando o arquivo mudou ou ainda não tem metadados, em vez de só quando file_size está vazio:

  1. Antes do super().save(), detectar se o media_file mudou comparando o nome atual com o persistido no banco (self.__class__.objects.filter(pk=self.pk).values_list("media_file", ...)); registro novo (self.pk is None) conta como mudança
  2. Após o super().save(), se file_size estiver vazio (primeiro upload) ou o arquivo tiver mudado: recomputar file_size (via default_storage.size(name)) e duration (via mutagen.mp3.MP3), persistindo com update_fields=["file_size", "duration"]
  3. Se a duração mudou para um registro existente, chamar _sync_progress_durations()

Sincronizar total_duration dos progressos

Novo método no MediaMetadataMixin que resolve o model de progresso pelos atributos já existentes na classe concreta e faz um único UPDATE em lote:

python def _sync_progress_durations(self): label = getattr(self, "progress_model_label", "") field = getattr(self, "progress_field_name", "") if not label or not field: return progress_model = django_apps.get_model(*label.split(".")) progress_model.objects.filter(**{field: self}).update(total_duration=self.duration)

Usa .update() (queryset) — não passa pelo save() do progresso, evitando o branch que só sobrescreve total_duration quando está vazio.

Trade-offs aceitos

  • Um progresso já marcado como concluído contra a duração antiga (ex.: áudio de 60s, novo de 600s) continua concluído — completed_at não é reavaliado, conforme decisão. Só total_duration é corrigido
  • Detecção de troca por nome de arquivo: reupload com nome de storage idêntico (raro, o storage normalmente gera nome único) não é detectado

Arquivos

  • apps/audios/models/media_base.py — MediaMetadataMixin.save() recalcula duration/file_size ao detectar troca de arquivo; novos helpers _media_file_changed() e _sync_progress_durations(). Beneficia Audio e Meditation automaticamente
  • tests/audios/ — testes de: troca de media_file recomputa duration e file_size (mock de default_storage.open e mutagen.mp3.MP3); total_duration dos progressos é atualizado com position/completed_at inalterados; salvar sem trocar o arquivo não recomputa; criação continua calculando os metadados

Como verificar

  1. Unit tests acima passando
  2. Local/admin: criar um áudio e gerar um AudioProgress (ex.: position 290, total_duration 300). Editar o áudio trocando o arquivo por um de duração diferente → conferir que Audio.duration/file_size mudaram e que AudioProgress.total_duration reflete a nova duração, com position/completed_at preservados
  3. Repetir o passo 2 para Meditation/MeditationProgress
  4. Editar o áudio sem trocar o arquivo → duration, file_size e progressos inalterados (sem abrir o arquivo)

Documentação