feat: implement BP-04 audio conversion core

This commit is contained in:
Rodrigo Verdiani 2026-07-06 20:53:16 -03:00
parent a856850451
commit b3a7956e98
6 changed files with 500 additions and 11 deletions

View File

@ -4,7 +4,7 @@
- **Feature:** Conversão FLAC→MP3 (ffmpeg) e validação pós-conversão (ffprobe)
- **Requisitos:** RF-003, RF-005, RF-006, RF-010
- **Depende de:** BP-03
- **Status:** pendente
- **Status:** concluído
## Objetivo
@ -61,23 +61,23 @@ func (v *Validator) Validate(ctx context.Context, mp3Paths []string) error
## Tarefas
- [ ] Criar `internal/transcoder/transcoder.go` (`New`, `ConvertFile`, `buildArgs`).
- [ ] Criar `internal/validator/validator.go` (`New`, `Validate`).
- [ ] Garantir tratamento de erro com stderr anexado.
- [ ] Respeitar `DRY_RUN` no `ConvertFile`.
- [x] Criar `internal/transcoder/transcoder.go` (`New`, `ConvertFile`, `buildArgs`).
- [x] Criar `internal/validator/validator.go` (`New`, `Validate`).
- [x] Garantir tratamento de erro com stderr anexado.
- [x] Respeitar `DRY_RUN` no `ConvertFile`.
## Testes
Helper comum: localizar ffmpeg/ffprobe; se ausente, `t.Skip`. Gerar um FLAC curto de teste com ffmpeg (ex.: `ffmpeg -f lavfi -i sine=frequency=440:duration=1 -c:a flac out.flac`).
`internal/transcoder/transcoder_test.go`:
- [ ] `buildArgs` contém `-c:a libmp3lame`, `-b:a 320k`, `-map_metadata 0`, destino esperado (teste unitário, sem executar processo).
- [ ] Integração: gerar FLAC em `t.TempDir()`, `ConvertFile` cria o `.mp3` (>0 bytes).
- [ ] Dry run: com `DRY_RUN=true` no config, `ConvertFile` não cria arquivo e não erra.
- [x] `buildArgs` contém `-c:a libmp3lame`, `-b:a 320k`, `-map_metadata 0`, destino esperado (teste unitário, sem executar processo).
- [x] Integração: gerar FLAC em `t.TempDir()`, `ConvertFile` cria o `.mp3` (>0 bytes).
- [x] Dry run: com `DRY_RUN=true` no config, `ConvertFile` não cria arquivo e não erra.
`internal/validator/validator_test.go`:
- [ ] MP3 válido (gerado no teste) passa.
- [ ] Arquivo ausente → erro; arquivo de 0 byte → erro.
- [x] MP3 válido (gerado no teste) passa.
- [x] Arquivo ausente → erro; arquivo de 0 byte → erro.
## Definição de Pronto

View File

@ -27,7 +27,7 @@ O que **falta** está coberto pelos blueprints abaixo.
| 01 | [Persistence & Models](01-persistence-and-models.md) ~~~✅~~~ | Persistência SQLite + tipos | RF-013, RNF-005 | — |
| 02 | [Structured Logging](02-structured-logging.md) ~~~✅~~~ | Logs estruturados no SQLite | RF-008 | 01 |
| 03 | [Runtime Configuration](03-runtime-configuration.md) ~~~✅~~~ | Config env + overrides SQLite | RF-011, RF-016 | 01 |
| 04 | [Audio Conversion Core](04-audio-conversion-core.md) | Transcode + validação | RF-003, RF-005, RF-006, RF-010 | 03 |
| 04 | [Audio Conversion Core](04-audio-conversion-core.md) ~~~✅~~~ | Transcode + validação | RF-003, RF-005, RF-006, RF-010 | 03 |
| 05 | [Job Pipeline](05-job-pipeline.md) | Fila + scheduler + workers | RF-002, RF-004, RF-014 | 01, 03, 04 |
| 06 | [Lidarr Integration](06-lidarr-integration.md) | Cliente Lidarr (rescan) | RF-007 | 05 |
| 07 | [Webhook Trigger](07-webhook-trigger.md) | Endpoint de webhook | RF-001, RF-012 | 05, 06 |

View File

@ -0,0 +1,71 @@
package transcoder
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"coda/internal/config"
)
type Transcoder struct {
cfg *config.Service
}
func New(cfg *config.Service) *Transcoder {
return &Transcoder{cfg: cfg}
}
func (t *Transcoder) ConvertFile(ctx context.Context, flacPath string) (string, error) {
cfg := t.cfg.Get()
ext := filepath.Ext(flacPath)
mp3Path := flacPath[:len(flacPath)-len(ext)] + ".mp3"
if cfg.DryRun {
slog.Info("dry run: would convert file", "flac", flacPath, "mp3", mp3Path)
return mp3Path, nil
}
tmpPath := mp3Path + ".tmp"
args := buildArgs(flacPath, tmpPath)
cmd := exec.CommandContext(ctx, cfg.FFmpegBin, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return "", fmt.Errorf("ffmpeg: %w: %s", err, stderr.String())
}
return "", fmt.Errorf("ffmpeg: %w", err)
}
if err := os.Rename(tmpPath, mp3Path); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("rename temp file: %w", err)
}
return mp3Path, nil
}
func buildArgs(flacPath, dstPath string) []string {
return []string{
"-i", flacPath,
"-c:a", "libmp3lame",
"-b:a", "320k",
"-map_metadata", "0",
"-id3v2_version", "3",
"-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
"-disposition:v", "attached_pic",
"-f", "mp3",
"-y",
dstPath,
}
}

View File

@ -0,0 +1,226 @@
package transcoder
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"coda/internal/config"
)
type mockStore struct {
data map[string]string
}
func (m *mockStore) GetAll(_ context.Context) (map[string]string, error) {
result := make(map[string]string, len(m.data))
for k, v := range m.data {
result[k] = v
}
return result, nil
}
func (m *mockStore) Set(_ context.Context, key, value string) error {
if m.data == nil {
m.data = map[string]string{}
}
m.data[key] = value
return nil
}
func (m *mockStore) Delete(_ context.Context, key string) error {
delete(m.data, key)
return nil
}
func newTestService(t *testing.T, overrides map[string]string) *config.Service {
t.Helper()
svc, err := config.NewService(context.Background(), &mockStore{data: overrides})
if err != nil {
t.Fatalf("NewService: %v", err)
}
return svc
}
func TestBuildArgs(t *testing.T) {
args := buildArgs("/music/Artist/Album/track.flac", "/music/Artist/Album/track.mp3")
inputIdx := -1
outputIdx := -1
hasLame := false
has320k := false
hasMapMetadata := false
hasID3v2 := false
hasMapAudio := false
hasMapVideoOpt := false
hasCopyVideo := false
hasAttachedPic := false
hasFmtMP3 := false
hasY := false
for i, a := range args {
switch a {
case "-i":
if i+1 < len(args) && args[i+1] == "/music/Artist/Album/track.flac" {
inputIdx = i
}
case "/music/Artist/Album/track.mp3":
outputIdx = i
case "-c:a":
if i+1 < len(args) && args[i+1] == "libmp3lame" {
hasLame = true
}
case "-b:a":
if i+1 < len(args) && args[i+1] == "320k" {
has320k = true
}
case "-map_metadata":
if i+1 < len(args) && args[i+1] == "0" {
hasMapMetadata = true
}
case "-id3v2_version":
if i+1 < len(args) && args[i+1] == "3" {
hasID3v2 = true
}
case "-map":
if i+1 < len(args) && args[i+1] == "0:a" {
hasMapAudio = true
}
if i+1 < len(args) && args[i+1] == "0:v?" {
hasMapVideoOpt = true
}
case "-c:v":
if i+1 < len(args) && args[i+1] == "copy" {
hasCopyVideo = true
}
case "-f":
if i+1 < len(args) && args[i+1] == "mp3" {
hasFmtMP3 = true
}
case "-disposition:v":
if i+1 < len(args) && args[i+1] == "attached_pic" {
hasAttachedPic = true
}
case "-y":
hasY = true
}
}
if inputIdx < 0 {
t.Error("missing -i flag for input file")
}
if outputIdx < 0 || outputIdx != len(args)-1 {
t.Error("output path should be the last argument")
}
if !hasLame {
t.Error("missing -c:a libmp3lame")
}
if !has320k {
t.Error("missing -b:a 320k")
}
if !hasMapMetadata {
t.Error("missing -map_metadata 0")
}
if !hasID3v2 {
t.Error("missing -id3v2_version 3")
}
if !hasMapAudio {
t.Error("missing -map 0:a")
}
if !hasMapVideoOpt {
t.Error("missing -map 0:v?")
}
if !hasCopyVideo {
t.Error("missing -c:v copy")
}
if !hasAttachedPic {
t.Error("missing -disposition:v attached_pic")
}
if !hasFmtMP3 {
t.Error("missing -f mp3")
}
if !hasY {
t.Error("missing -y (overwrite temp)")
}
}
func requireFFmpeg(t *testing.T) string {
t.Helper()
path, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not found, skipping integration test")
}
return path
}
func generateTestFLAC(t *testing.T, dir, name string, ffmpegPath string) string {
t.Helper()
flacPath := filepath.Join(dir, name)
cmd := exec.Command(ffmpegPath,
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-c:a", "flac",
"-y",
flacPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to generate test FLAC: %v\n%s", err, out)
}
return flacPath
}
func TestConvertFileIntegration(t *testing.T) {
ffmpegPath := requireFFmpeg(t)
t.Setenv("FFMPEG_BIN", ffmpegPath)
dir := t.TempDir()
flacPath := generateTestFLAC(t, dir, "test.flac", ffmpegPath)
svc := newTestService(t, nil)
tc := New(svc)
mp3Path, err := tc.ConvertFile(context.Background(), flacPath)
if err != nil {
t.Fatalf("ConvertFile: %v", err)
}
expectedMP3 := filepath.Join(dir, "test.mp3")
if mp3Path != expectedMP3 {
t.Errorf("mp3Path = %q, want %q", mp3Path, expectedMP3)
}
info, err := os.Stat(mp3Path)
if err != nil {
t.Fatalf("stat mp3: %v", err)
}
if info.Size() == 0 {
t.Error("generated mp3 is empty")
}
}
func TestConvertFileDryRun(t *testing.T) {
dir := t.TempDir()
flacPath := filepath.Join(dir, "dry.flac")
if err := os.WriteFile(flacPath, []byte("fake flac"), 0o644); err != nil {
t.Fatalf("write fake flac: %v", err)
}
svc := newTestService(t, map[string]string{"DRY_RUN": "true"})
tc := New(svc)
mp3Path, err := tc.ConvertFile(context.Background(), flacPath)
if err != nil {
t.Fatalf("ConvertFile dry run: %v", err)
}
expectedMP3 := filepath.Join(dir, "dry.mp3")
if mp3Path != expectedMP3 {
t.Errorf("mp3Path = %q, want %q", mp3Path, expectedMP3)
}
if _, err := os.Stat(mp3Path); err == nil {
t.Error("dry run should not create the mp3 file")
}
}

View File

@ -0,0 +1,65 @@
package validator
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"coda/internal/config"
)
type Validator struct {
cfg *config.Service
}
func New(cfg *config.Service) *Validator {
return &Validator{cfg: cfg}
}
func (v *Validator) Validate(ctx context.Context, mp3Paths []string) error {
cfg := v.cfg.Get()
if cfg.DryRun {
return nil
}
for _, path := range mp3Paths {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("validate %s: %w", path, err)
}
if info.Size() == 0 {
return fmt.Errorf("validate %s: file is empty", path)
}
if err := v.probe(ctx, cfg.FFprobeBin, path); err != nil {
return fmt.Errorf("validate %s: %w", path, err)
}
}
return nil
}
func (v *Validator) probe(ctx context.Context, ffprobeBin, path string) error {
cmd := exec.CommandContext(ctx, ffprobeBin,
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "csv=p=0",
path,
)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffprobe: %w", err)
}
if strings.TrimSpace(stdout.String()) == "" {
return fmt.Errorf("no audio stream detected")
}
return nil
}

View File

@ -0,0 +1,127 @@
package validator
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"coda/internal/config"
)
type mockStore struct {
data map[string]string
}
func (m *mockStore) GetAll(_ context.Context) (map[string]string, error) {
result := make(map[string]string, len(m.data))
for k, v := range m.data {
result[k] = v
}
return result, nil
}
func (m *mockStore) Set(_ context.Context, key, value string) error {
if m.data == nil {
m.data = map[string]string{}
}
m.data[key] = value
return nil
}
func (m *mockStore) Delete(_ context.Context, key string) error {
delete(m.data, key)
return nil
}
func newTestService(t *testing.T, overrides map[string]string) *config.Service {
t.Helper()
svc, err := config.NewService(context.Background(), &mockStore{data: overrides})
if err != nil {
t.Fatalf("NewService: %v", err)
}
return svc
}
func requireFFmpegFFprobe(t *testing.T) (ffmpegPath, ffprobePath string) {
t.Helper()
var err error
ffmpegPath, err = exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not found, skipping integration test")
}
ffprobePath, err = exec.LookPath("ffprobe")
if err != nil {
t.Skip("ffprobe not found, skipping integration test")
}
return ffmpegPath, ffprobePath
}
func generateTestMP3(t *testing.T, dir, name string, ffmpegPath string) string {
t.Helper()
mp3Path := filepath.Join(dir, name)
cmd := exec.Command(ffmpegPath,
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-c:a", "libmp3lame",
"-b:a", "320k",
"-y",
mp3Path,
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to generate test MP3: %v\n%s", err, out)
}
return mp3Path
}
func TestValidateValidMP3(t *testing.T) {
ffmpegPath, ffprobePath := requireFFmpegFFprobe(t)
t.Setenv("FFMPEG_BIN", ffmpegPath)
t.Setenv("FFPROBE_BIN", ffprobePath)
dir := t.TempDir()
mp3Path := generateTestMP3(t, dir, "valid.mp3", ffmpegPath)
svc := newTestService(t, nil)
v := New(svc)
if err := v.Validate(context.Background(), []string{mp3Path}); err != nil {
t.Errorf("Validate should pass for valid MP3: %v", err)
}
}
func TestValidateMissingFile(t *testing.T) {
svc := newTestService(t, nil)
v := New(svc)
err := v.Validate(context.Background(), []string{"/nonexistent/path.mp3"})
if err == nil {
t.Error("expected error for missing file")
}
}
func TestValidateEmptyFile(t *testing.T) {
dir := t.TempDir()
emptyPath := filepath.Join(dir, "empty.mp3")
if err := os.WriteFile(emptyPath, []byte{}, 0o644); err != nil {
t.Fatalf("write empty file: %v", err)
}
svc := newTestService(t, nil)
v := New(svc)
err := v.Validate(context.Background(), []string{emptyPath})
if err == nil {
t.Error("expected error for empty file")
}
}
func TestValidateDryRun(t *testing.T) {
svc := newTestService(t, map[string]string{"DRY_RUN": "true"})
v := New(svc)
if err := v.Validate(context.Background(), []string{"/nonexistent/path.mp3"}); err != nil {
t.Errorf("Validate should pass in dry run mode: %v", err)
}
}