Recalcular duração ao trocar o arquivo de áudio
TLDR: Ao substituir o
media_filede um áudio (ou meditação) já existente, recalcularduration/file_sizee atualizar ototal_durationdos 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_filede um áudio/meditação existente, recalculardurationefile_size - Ao recalcular a duração, atualizar o
total_durationde todos os progressos daquele conteúdo - Não alterar
positionnemcompleted_atdos progressos (decisão do dono — só sincronizartotal_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:
- Antes do
super().save(), detectar se omedia_filemudou 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 - Após o
super().save(), sefile_sizeestiver vazio (primeiro upload) ou o arquivo tiver mudado: recomputarfile_size(viadefault_storage.size(name)) eduration(viamutagen.mp3.MP3), persistindo comupdate_fields=["file_size", "duration"] - 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_atnã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()recalculaduration/file_sizeao detectar troca de arquivo; novos helpers_media_file_changed()e_sync_progress_durations(). BeneficiaAudioeMeditationautomaticamentetests/audios/— testes de: troca demedia_filerecomputadurationefile_size(mock dedefault_storage.openemutagen.mp3.MP3);total_durationdos progressos é atualizado composition/completed_atinalterados; salvar sem trocar o arquivo não recomputa; criação continua calculando os metadados
Como verificar
- Unit tests acima passando
- Local/admin: criar um áudio e gerar um
AudioProgress(ex.:position290,total_duration300). Editar o áudio trocando o arquivo por um de duração diferente → conferir queAudio.duration/file_sizemudaram e queAudioProgress.total_durationreflete a nova duração, composition/completed_atpreservados - Repetir o passo 2 para
Meditation/MeditationProgress - Editar o áudio sem trocar o arquivo →
duration,file_sizee progressos inalterados (sem abrir o arquivo)
Documentação
- learnings/audio_duration_recalc_on_file_change.md — o recálculo depende da troca de arquivo e exige sincronizar o snapshot
total_durationdos progressos