package library
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"intake/internal/artwork"
"intake/internal/audio"
"intake/internal/tags"
)
type Options struct {
Codec string
Quality int
ArtMaxPx int
ArtQuality int
CoverPath string
VerifyDecode bool
Logf func(format string, args ...any)
}
type Result struct {
Dir string
Files []string
Bytes int64
Cover string
Skipped bool
Warnings []string
}
const DefaultQuality = 9
// Ext is the file extension a codec's library files are written with. It is
// exported because anything comparing a library copy against its archive has to
// agree with this, and hard-coding ".ogg" made every opus library look broken.
func Ext(codec string) string {
if strings.EqualFold(strings.TrimSpace(codec), "opus") {
return ".opus"
}
return ".ogg"
}
func Transcode(ctx context.Context, masterPaths []string, targetNames []string, outDir string, opts Options) (Result, error) {
logf := opts.Logf
if logf == nil {
logf = func(string, ...any) {}
}
var res Result
res.Dir = outDir
if len(masterPaths) != len(targetNames) {
return res, fmt.Errorf("internal error: %d masters but %d target names", len(masterPaths), len(targetNames))
}
if len(masterPaths) == 0 {
return res, fmt.Errorf("no masters to transcode")
}
codec := strings.ToLower(strings.TrimSpace(opts.Codec))
if codec == "" {
// vorbis by default: ffmpeg 9 drops tags on opus and can't attach cover art
// to ogg at all, so opus needs the opustags binary and still won't carry a
// picture. the masters are lossless, so this can be redone in anything later.
codec = "vorbis"
}
if codec == "opus" && !hasTool("opustags") {
return res, fmt.Errorf("the opus codec needs the opustags tool, which is not installed; use the vorbis codec or install opustags")
}
quality := opts.Quality
if quality <= 0 {
quality = DefaultQuality
}
artMax := opts.ArtMaxPx
if artMax <= 0 {
artMax = 1000
}
artQuality := opts.ArtQuality
if artQuality <= 0 {
artQuality = 85
}
parent := filepath.Dir(outDir)
if err := os.MkdirAll(parent, 0o755); err != nil {
return res, err
}
tmp, err := os.MkdirTemp(parent, "."+filepath.Base(outDir)+".intake-lib-")
if err != nil {
return res, err
}
defer os.RemoveAll(tmp)
embedCover := ""
if opts.CoverPath != "" {
if _, err := os.Stat(opts.CoverPath); err == nil {
libCover := filepath.Join(tmp, "cover.jpg")
size, err := artwork.Optimize(ctx, opts.CoverPath, artMax, artQuality, libCover)
if err != nil {
// magick writes to libCover as it goes, so a failure can leave a
// half-written file there. It would then travel with the rest of
// the album into the library, where nothing ever decodes it and
// every check only asks whether a cover.jpg exists.
os.Remove(libCover)
res.Warnings = append(res.Warnings, fmt.Sprintf("could not optimise the cover: %v", err))
} else {
embedCover = libCover
res.Cover = filepath.Join(outDir, "cover.jpg")
logf("cover: optimised to %dx%d max (%s)", artMax, artMax, humanBytes(size))
}
} else {
// Worth saying out loud: the album is about to be given a library
// copy with no artwork at all, and the usual cause is a cover that
// lived in the staging folder and has just been cleared away.
res.Warnings = append(res.Warnings, fmt.Sprintf("no library cover: %s cannot be read (%v)", opts.CoverPath, err))
}
}
outExt := Ext(codec)
written := make([]string, 0, len(masterPaths))
for i, master := range masterPaths {
name := strings.TrimSuffix(targetNames[i], filepath.Ext(targetNames[i])) + outExt
dst := filepath.Join(tmp, name)
if err := encode(ctx, master, dst, codec, quality); err != nil {
return res, err
}
comments, err := masterComments(ctx, master)
if err != nil {
return res, fmt.Errorf("reading tags from %s: %w", filepath.Base(master), err)
}
if embedCover != "" {
pic, err := pictureComment(ctx, embedCover)
if err != nil {
return res, err
}
comments = append(comments, pic)
}
if err := writeComments(ctx, dst, codec, comments); err != nil {
return res, err
}
if err := verifyOne(ctx, master, dst, opts.VerifyDecode); err != nil {
return res, err
}
written = append(written, dst)
logf("encoded %s", name)
}
// Swap the finished library into place without ever leaving neither copy on
// disk. Removing the old directory first -- and RemoveAll unlinks as it goes,
// so a failure part-way through leaves it half deleted -- destroyed the old
// library before the new one had landed, and the new one was thrown away by
// the deferred cleanup because the rename never happened.
if _, err := os.Stat(outDir); err == nil {
stale := outDir + ".intake-stale"
os.RemoveAll(stale)
if err := os.Rename(outDir, stale); err != nil {
return res, fmt.Errorf("moving the existing library aside: %w", err)
}
if err := os.Rename(tmp, outDir); err != nil {
if back := os.Rename(stale, outDir); back != nil {
return res, fmt.Errorf("promoting library directory: %w (the previous library is now at %s)", err, stale)
}
return res, fmt.Errorf("promoting library directory: %w", err)
}
if err := os.RemoveAll(stale); err != nil {
logf("note: the replaced library is still at %s: %v", filepath.Base(stale), err)
}
} else if err := os.Rename(tmp, outDir); err != nil {
return res, fmt.Errorf("promoting library directory: %w", err)
}
res.Files = make([]string, 0, len(written))
for _, w := range written {
res.Files = append(res.Files, filepath.Join(outDir, filepath.Base(w)))
}
for _, f := range res.Files {
if info, err := os.Stat(f); err == nil {
res.Bytes += info.Size()
}
}
if res.Cover != "" {
if info, err := os.Stat(res.Cover); err == nil {
res.Bytes += info.Size()
}
}
logf("library: %d track(s), %s", len(res.Files), humanBytes(res.Bytes))
return res, nil
}
func encode(ctx context.Context, src, dst, codec string, quality int) error {
var args []string
switch codec {
case "vorbis":
args = []string{"-c:a", "libvorbis", "-q:a", strconv.Itoa(quality)}
case "opus":
args = []string{"-c:a", "libopus", "-b:a", strconv.Itoa(quality) + "k"}
default:
return fmt.Errorf("unknown codec %q", codec)
}
full := append([]string{"-hide_banner", "-loglevel", "error", "-y",
"-i", src, "-map", "0:a", "-map_metadata", "-1"}, args...)
full = append(full, dst)
var errb bytes.Buffer
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return fmt.Errorf("encoding %s: %s", filepath.Base(src), strings.TrimSpace(errb.String()))
}
return nil
}
func masterComments(ctx context.Context, master string) ([]string, error) {
cmd := exec.CommandContext(ctx, "metaflac", "--export-tags-to=-", master)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("%s", strings.TrimSpace(errb.String()))
}
return filterComments(strings.Split(out.String(), "\n")), nil
}
func filterComments(lines []string) []string {
var comments []string
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
key, _, ok := strings.Cut(line, "=")
if !ok || !tags.IsKept(key) {
continue
}
comments = append(comments, line)
}
return comments
}
func writeComments(ctx context.Context, path, codec string, comments []string) error {
switch codec {
case "vorbis":
file, err := os.CreateTemp("", "intake-comments-*.txt")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err := file.WriteString(strings.Join(comments, "\n") + "\n"); err != nil {
file.Close()
return err
}
file.Close()
cmd := exec.CommandContext(ctx, "vorbiscomment", "-a", "-c", file.Name(), path)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("tagging %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
}
return nil
case "opus":
args := []string{"-a"}
for _, c := range comments {
args = append(args, "-t", c)
}
args = append(args, path)
cmd := exec.CommandContext(ctx, "opustags", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("tagging %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
}
return nil
}
return fmt.Errorf("unknown codec %q", codec)
}
func verifyOne(ctx context.Context, master, dst string, decode bool) error {
out, err := audio.Probe(ctx, dst)
if err != nil {
return fmt.Errorf("library verification failed reading %s: %w", filepath.Base(dst), err)
}
if out.Codec == "" {
return fmt.Errorf("library verification failed: %s has no audio stream", filepath.Base(dst))
}
in, err := audio.Probe(ctx, master)
if err != nil {
return err
}
if in.Duration > 0 && out.Duration > 0 {
diff := out.Duration - in.Duration
if diff < 0 {
diff = -diff
}
if diff > 0.25 {
return fmt.Errorf("library verification failed: %s duration drifted by %.3fs", filepath.Base(dst), diff)
}
}
for _, field := range []string{tags.FieldTitle, tags.FieldAlbum, tags.FieldAlbumArtist, tags.FieldTrackNumber} {
want, _ := tags.Get(in.Tags, field)
got, ok := tags.Get(out.Tags, field)
if !ok || strings.TrimSpace(got) == "" {
return fmt.Errorf("library verification failed: %s is missing %s", filepath.Base(dst), field)
}
if want != got {
return fmt.Errorf("library verification failed: %s has %s=%q, master has %q", filepath.Base(dst), field, got, want)
}
}
if decode {
if err := audio.DecodeOK(ctx, dst); err != nil {
return fmt.Errorf("library verification failed: %w", err)
}
}
return nil
}
func pictureComment(ctx context.Context, imagePath string) (string, error) {
data, err := os.ReadFile(imagePath)
if err != nil {
return "", err
}
mime := "image/jpeg"
if strings.EqualFold(filepath.Ext(imagePath), ".png") {
mime = "image/png"
}
var buf bytes.Buffer
writeU32 := func(n uint32) {
buf.WriteByte(byte(n >> 24))
buf.WriteByte(byte(n >> 16))
buf.WriteByte(byte(n >> 8))
buf.WriteByte(byte(n))
}
writeU32(3)
writeU32(uint32(len(mime)))
buf.WriteString(mime)
writeU32(0)
writeU32(0)
writeU32(0)
writeU32(0)
writeU32(0)
writeU32(uint32(len(data)))
buf.Write(data)
return "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
func hasTool(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
}