feat: implement BP-04 audio conversion core
This commit is contained in:
parent
a856850451
commit
b3a7956e98
@ -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
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
71
internal/transcoder/transcoder.go
Normal file
71
internal/transcoder/transcoder.go
Normal 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,
|
||||
}
|
||||
}
|
||||
226
internal/transcoder/transcoder_test.go
Normal file
226
internal/transcoder/transcoder_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
65
internal/validator/validator.go
Normal file
65
internal/validator/validator.go
Normal 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
|
||||
}
|
||||
127
internal/validator/validator_test.go
Normal file
127
internal/validator/validator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user