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
}