git/intake/main
internal/audio/audio.go
267 lines8 KB last change raw · history
package audio

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"

	"intake/internal/tags"
)

var Exts = map[string]bool{
	".flac": true,
	".ogg":  true,
	".oga":  true,
	".opus": true,
	".mp3":  true,
	".m4a":  true,
	".wav":  true,
	".ape":  true,
	".wv":   true,
	".aiff": true,
	".aif":  true,
}

func IsAudio(name string) bool {
	// macOS writes an AppleDouble sidecar next to each file it touches on a
	// foreign filesystem, named "._" plus the original name. It keeps the audio
	// extension, so by extension alone it looks like a track: it was left behind
	// in staging after everything else was cleaned up, and it is not audio.
	if strings.HasPrefix(name, "._") {
		return false
	}
	return Exts[strings.ToLower(filepath.Ext(name))]
}

type Track struct {
	Path        string
	Ext         string
	Tags        []tags.Note
	Duration    float64
	SampleRate  int
	Channels    int
	BitsPerSamp int
	Codec       string
	ArtCount    int
	DecodeError string
	// DiscHint is the disc number taken from the folder the file sits in, for
	// rips that put each disc in a CD1 / Disc 2 folder and never tag it. It is
	// only a fallback: a DISCNUMBER tag or a disc number in the file name wins.
	DiscHint int
}

func Probe(ctx context.Context, path string) (Track, error) {
	t := Track{Path: path, Ext: strings.ToLower(filepath.Ext(path))}
	if err := probeStream(ctx, &t); err != nil {
		return t, err
	}
	switch t.Ext {
	case ".flac":
		notes, err := metaflacTags(ctx, path)
		if err != nil {
			return t, err
		}
		t.Tags = notes
		if n, err := metaflacPictureCount(ctx, path); err == nil {
			t.ArtCount = n
		}
	case ".ogg", ".oga", ".opus":
		notes, err := vorbiscommentTags(ctx, path)
		if err != nil {
			return t, err
		}
		t.Tags = notes
		t.ArtCount = countPictureComments(notes)
	default:
		// mp3 and anything else ffprobe understands. Without this the tags of
		// every mp3 were ignored entirely, so a mixed album looked like a set of
		// untagged files and was refused, and a pure mp3 album could not resolve
		// its own artist or title.
		if t.Tags == nil {
			t.Tags = []tags.Note{}
		}
	}
	return t, nil
}

func vorbiscommentTags(ctx context.Context, path string) ([]tags.Note, error) {
	cmd := exec.CommandContext(ctx, "vorbiscomment", "-l", path)
	var out, errb bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("vorbiscomment %s: %s", filepath.Base(path), strings.TrimSpace(errb.String()))
	}
	lines := strings.Split(out.String(), "\n")
	return tags.Parse(lines), nil
}

func countPictureComments(notes []tags.Note) int {
	n := 0
	for _, note := range notes {
		switch note.Field {
		case "METADATA_BLOCK_PICTURE", "COVERART":
			n++
		}
	}
	return n
}

type ffprobeOut struct {
	Streams []struct {
		CodecName     string `json:"codec_name"`
		SampleRate    string `json:"sample_rate"`
		Channels      int    `json:"channels"`
		BitsPerSample int    `json:"bits_per_sample"`
		Disposition   struct {
			AttachedPic int `json:"attached_pic"`
		} `json:"disposition"`
	} `json:"streams"`
	Format struct {
		Duration string            `json:"duration"`
		Tags     map[string]string `json:"tags"`
	} `json:"format"`
}

func probeStream(ctx context.Context, t *Track) error {
	cmd := exec.CommandContext(ctx, "ffprobe",
		"-v", "error",
		"-show_entries", "stream=codec_name,sample_rate,channels,bits_per_sample:stream_disposition=attached_pic:format=duration:format_tags",
		"-of", "json", t.Path)
	var out, errb bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		msg := strings.TrimSpace(errb.String())
		if msg == "" {
			msg = err.Error()
		}
		t.DecodeError = msg
		return fmt.Errorf("ffprobe %s: %s", filepath.Base(t.Path), msg)
	}
	var parsed ffprobeOut
	if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
		return fmt.Errorf("ffprobe %s: unreadable output: %w", filepath.Base(t.Path), err)
	}
	for _, s := range parsed.Streams {
		if s.Disposition.AttachedPic == 1 {
			t.ArtCount++
			continue
		}
		if t.Codec == "" {
			t.Codec = s.CodecName
			t.SampleRate, _ = strconv.Atoi(s.SampleRate)
			t.Channels = s.Channels
			t.BitsPerSamp = s.BitsPerSample
		}
	}
	if d, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil {
		t.Duration = d
	}
	// Kept for formats that have no better reader below. FLAC and Ogg are read
	// with their own tools because ffprobe does not see their comments reliably.
	if len(parsed.Format.Tags) > 0 {
		lines := make([]string, 0, len(parsed.Format.Tags))
		for k, v := range parsed.Format.Tags {
			lines = append(lines, k+"="+v)
		}
		t.Tags = tags.Parse(lines)
	}
	return nil
}

func metaflacTags(ctx context.Context, path string) ([]tags.Note, error) {
	cmd := exec.CommandContext(ctx, "metaflac", "--export-tags-to=-", path)
	var out, errb bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("metaflac %s: %s", filepath.Base(path), strings.TrimSpace(errb.String()))
	}
	lines := strings.Split(out.String(), "\n")
	return tags.Parse(lines), nil
}

func metaflacPictureCount(ctx context.Context, path string) (int, error) {
	cmd := exec.CommandContext(ctx, "metaflac", "--list", "--block-type=PICTURE", path)
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &bytes.Buffer{}
	if err := cmd.Run(); err != nil {
		return 0, err
	}
	return strings.Count(out.String(), "type: 6 (PICTURE)"), nil
}

func HasTool(name string) bool {
	_, err := exec.LookPath(name)
	return err == nil
}

func WriteTags(ctx context.Context, path string, lines []string, stripPictures bool) error {
	if stripPictures {
		if out, err := exec.CommandContext(ctx, "metaflac", "--remove", "--block-type=PICTURE", path).CombinedOutput(); err != nil {
			return fmt.Errorf("metaflac remove pictures %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
		}
	}
	args := []string{"--remove-all-tags"}
	if len(lines) > 0 {
		args = append(args, "--import-tags-from=-")
	}
	args = append(args, path)
	cmd := exec.CommandContext(ctx, "metaflac", args...)
	if len(lines) > 0 {
		cmd.Stdin = strings.NewReader(strings.Join(lines, "\n") + "\n")
	}
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("metaflac write tags %s: %s", filepath.Base(path), strings.TrimSpace(string(out)))
	}
	return nil
}

// TestFLAC checks a FLAC file against the reference decoder.
//
// ffmpeg is not enough for this. A truncated FLAC, which is what a
// half-finished download is, makes ffmpeg exit zero having decoded nothing at
// all, so the file passes a decode check and gets promoted into the archive.
// flac --test decodes the whole stream and checks it against the MD5 stored in
// the file's own header, which is what actually catches a partial file. It runs
// at roughly a thousand times realtime, so it costs about a fifth of a second
// per track.
//
// Returns nil when no flac binary is installed; the caller's decode check still
// runs, it just cannot see truncation in that case.
func TestFLAC(ctx context.Context, path string) error {
	if _, err := exec.LookPath("flac"); err != nil {
		return nil
	}
	cmd := exec.CommandContext(ctx, "flac", "--test", "--silent", path)
	var errb bytes.Buffer
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		// flac names the file in its own message, so this does not.
		msg := strings.TrimSpace(errb.String())
		if msg == "" {
			msg = fmt.Sprintf("%s: %v", filepath.Base(path), err)
		}
		return errors.New(msg)
	}
	return nil
}

func DecodeOK(ctx context.Context, path string) error {
	cmd := exec.CommandContext(ctx, "ffmpeg", "-v", "error", "-i", path, "-f", "null", "-")
	var errb bytes.Buffer
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("decode %s: %s", filepath.Base(path), strings.TrimSpace(errb.String()))
	}
	return nil
}