// Package library — хранилища, сканер файлов и каталог (docs/library-and-storage.md). package library import ( "context" "encoding/json" "fmt" "math" "os/exec" "path" "strconv" "strings" "time" "pipu/api/internal/library/meta" ) // AudioExtensions — расширения, которые сканер считает аудио. var AudioExtensions = map[string]bool{ ".flac": true, ".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".opus": true, ".wav": true, ".aiff": true, ".aif": true, ".wv": true, ".ape": true, } // IsAudio сообщает, похоже ли имя файла на аудио. func IsAudio(name string) bool { return AudioExtensions[strings.ToLower(path.Ext(name))] } // Probe — технические данные и теги файла. type Probe struct { Format string // по расширению: flac, mp3, ... Codec string Bitrate int // бит/с SampleRate int BitDepth int Channels int DurationMs int Tags meta.Fields RawTags map[string]string } // Lossless сообщает, без потерь ли кодек. func (p Probe) Lossless() bool { switch p.Codec { case "flac", "alac", "wavpack", "ape", "tta": return true } return strings.HasPrefix(p.Codec, "pcm_") } // Prober читает данные аудиофайла. type Prober interface { Probe(ctx context.Context, absPath string) (Probe, error) } // FFprobe — Prober на основе ffprobe (тот же бинарник нужен для транскодинга). type FFprobe struct { Bin string Timeout time.Duration } type ffprobeOutput struct { Format struct { Duration string `json:"duration"` BitRate string `json:"bit_rate"` Tags map[string]string `json:"tags"` } `json:"format"` Streams []struct { CodecType string `json:"codec_type"` CodecName string `json:"codec_name"` SampleRate string `json:"sample_rate"` Channels int `json:"channels"` BitsPerRawSample string `json:"bits_per_raw_sample"` BitsPerSample int `json:"bits_per_sample"` BitRate string `json:"bit_rate"` Duration string `json:"duration"` Tags map[string]string `json:"tags"` } `json:"streams"` } // Probe реализует Prober. Путь передаётся отдельным аргументом, без shell. func (f FFprobe) Probe(ctx context.Context, absPath string) (Probe, error) { bin := f.Bin if bin == "" { bin = "ffprobe" } if f.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, f.Timeout) defer cancel() } out, err := exec.CommandContext(ctx, bin, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "--", absPath).Output() if err != nil { if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { return Probe{}, fmt.Errorf("ffprobe: %s", strings.TrimSpace(string(ee.Stderr))) } return Probe{}, fmt.Errorf("ffprobe: %w", err) } return parseFFprobe(out, absPath) } func parseFFprobe(raw []byte, name string) (Probe, error) { var o ffprobeOutput if err := json.Unmarshal(raw, &o); err != nil { return Probe{}, fmt.Errorf("ffprobe: decode: %w", err) } p := Probe{Format: strings.TrimPrefix(strings.ToLower(path.Ext(name)), ".")} tags := map[string]string{} addTags := func(m map[string]string) { for k, v := range m { k = strings.ToLower(k) if _, ok := tags[k]; !ok && strings.TrimSpace(v) != "" { tags[k] = v } } } addTags(o.Format.Tags) found := false for _, s := range o.Streams { if s.CodecType != "audio" || found { continue } found = true p.Codec = s.CodecName p.SampleRate = atoi(s.SampleRate) p.Channels = s.Channels p.BitDepth = atoi(s.BitsPerRawSample) if p.BitDepth == 0 { p.BitDepth = s.BitsPerSample } p.Bitrate = atoi(s.BitRate) if p.DurationMs == 0 { p.DurationMs = seconds(s.Duration) } // Ogg / Opus хранят теги в потоке, а не в контейнере. addTags(s.Tags) } if !found { return Probe{}, fmt.Errorf("ffprobe: no audio stream") } if d := seconds(o.Format.Duration); d > 0 { p.DurationMs = d } if p.Bitrate == 0 { p.Bitrate = atoi(o.Format.BitRate) } first := func(keys ...string) string { for _, k := range keys { if v := tags[k]; v != "" { return v } } return "" } p.Tags = meta.Fields{ Title: first("title"), Artist: first("artist"), AlbumArtist: first("album_artist", "albumartist", "album artist", "band"), Album: first("album"), Genre: first("genre"), Year: first("date", "year", "originaldate", "tdrc"), Track: first("track", "tracknumber"), Disc: first("disc", "discnumber"), } p.RawTags = tags return p, nil } func atoi(s string) int { n, _ := strconv.Atoi(strings.TrimSpace(s)) return n } func seconds(s string) int { f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) if err != nil || f <= 0 || math.IsInf(f, 0) { return 0 } return int(math.Round(f * 1000)) }