Files
pipu-music/services/api/internal/library/probe.go
T
mrleo1nid 39bf7d4b70
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
feat(uploads): match release groups with MusicBrainz and AcoustID
2026-10-29 20:44:59 +03:00

180 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}