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]) }