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 }