Local Player: progresso atômico que viaja com o HD
Como writeFileAtomic + fila serial + .bak + .corrupt-<ts> eliminaram perda de posição mesmo matando o servidor no meio do save.
Perder onde eu parei no vídeo era o bug que mais me irritava. Então eu tratei progresso como dado crítico.
O contexto
Na parte 1 eu contei a arquitetura geral do Local Player. Aqui eu vou contar do subsistema que me fez perder mais sono: salvar a posição da aula em server/progress/store.js.
Eu decidi que o progresso ia morar junto da biblioteca, em <lib>/.courseplayer/progress.json. Assim ele viaja com o HD ou pendrive — eu desplugo, levo pra outra máquina, e continuo de onde parei. Cada entrada é {position, duration, completed, updatedAt} numa chave libId\0rel.
O problema que eu queria resolver
Era sempre a mesma história: eu fechava o servidor no meio do save, faltava luz, ou o progress.json corrompia — e eu perdia tudo. Ou pior: eu limpava o cache de transcode e junto ia o progresso.
Minha regra virou: save normal nunca apaga chave, nunca tira um completed, nunca perde posição válida. O frontend também tinha que colaborar, com throttle e umas travas contra regressão.
A decisão mais importante
Três camadas simples no backend:
- escrita atômica (
writeFileAtomic: escreve num tmp, dá fsync, renomeia, dá fsync no diretório); - fila serial (
updateProgress) — um save por vez, nunca dois juntos; - backup duplo (
.bake.bak.1; se o principal corromper, preserva como.corrupt-<ts>e restaura do backup).
save → tmp exclusivo → fsync arquivo → rename → fsync dir
corrompido → preserva .corrupt-<ts> → tenta .bak → tenta .bak.1
Eu pensei em usar SQLite com WAL, mas ia puxar dependência nativa e complicar em FAT/exFAT. JSON atômico com backup cobre meu volume real e qualquer um consegue abrir e auditar.
flowchart TD
Player["timeupdate / pause / ended"] --> Fila["updateProgress (serial)"]
Fila --> Atomica["writeFileAtomic"]
Atomica --> Disco["progress.json"]
Disco --> Backup[".bak / .bak.1"]
Como usei IA no desenvolvimento
Ela foi ótima pra gerar a matriz de testes (progress-invariance, progress-persistence, progress-forensic, library-progress) — regressão de posição, completed preservado, isolamento por biblioteca.
Mas tentou me convencer a fazer escrita paralela com debounce agressivo no backend pra “otimizar”. Eu tive que travar a regra no AGENTS.md: fila serial, sem exceção.
E a validação eu fiz na marra: escrevi progresso, matei o servidor no meio, reiniciei e conferi se voltou. Corrompi o JSON de propósito pra ver o .corrupt-<ts> nascer e o restore funcionar.
O que eu descobri implementando
- Limpar é cirúrgico: por prefixo
<curso>/ou global. E oPOST /api/transcode/clearlimpa transcode e cancela jobs, mas nunca encosta noprogress.json— tem teste só pra isso. - Rescan nunca apaga progresso.
- No frontend (
public/js/player.js):timeupdatecom throttle de 5s,pause/ended/beforeunloadcomsendBeacon; acima de 95% marca concluído; reassistir não desmarca; posição 0 não apaga; sem metadata não grava; retomada só se3 < position < duration - 2. - Remover biblioteca é só config (com job ativo dá 409); a padrão não sai (403).
O que funcionou
LP_DATA_DIRpra rodar os testes com servidor real num diretório temporário, sem encostar nos meus dados.LP_PROGRESS_FORENSIC=1quando eu preciso de log verboso.- Chave por biblioteca acabou com vazamento de progresso entre HD interno e pendrive.
O que não funcionou
- O primeiro
fs.writeFiledireto: kill no meio gerava JSON truncado e perda total. O backup só prestou depois da atomicidade. - Tentei persistir pastas abertas da sidebar junto e desisti — a árvore re-renderiza muito e brigava com o salvo.
O que eu faria diferente hoje
Ter escrito as travas (“save normal nunca…”) como teste antes do código, não depois. Muito bug da IA teria caído no primeiro node --test.
Resultado
Hoje passa: kill no meio preserva, corrompido vira .corrupt-<ts> e restaura, clear de transcode não afeta progresso, rescan preserva tudo.
Falta: compactar o progress.json pra biblioteca gigante — hoje reescreve o arquivo todo a cada save.
O que aprendi
1. Atomicidade é barata
Tmp + fsync + rename resolve 90% sem dependência nova.
2. “Não regredir” é especificação
Tem que ser teste, não comentário.
3. Formato segue portabilidade
JSON no .courseplayer viaja com o disco; banco local prenderia meu dado à máquina.
Próximos passos
- Parte 1: vibecoding com invariantes e zero build step
- Parte 3: fallback de transcode e Whisper P0-P3
Conclusão
Progresso confiável não precisa de stack nova. Precisa de ordem total nas escritas, fsync no lugar certo e backup testado com kill de verdade.
Referências
server/progress/store.js,server/core/fs-atomic.js,server/progress/routes.jstest/progress-invariance.test.js,test/progress-persistence.test.js,test/progress-forensic.test.jsdocs/VALIDATION.mdseção 4