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
180 lines
5.1 KiB
Go
180 lines
5.1 KiB
Go
// 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))
|
||
}
|