git/intake/main
internal/library/library.go
366 lines11 KB last change raw · history
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])
}