Files
pipu-music/services/api/internal/library/probe.go
T
mrleo1nidandClaude Opus 5.5 c583991d14
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
feat(api): library sources, scanner and catalog (M2)
- schema for sources, path patterns, media files, artists, albums,
  tracks, genres (migration 00003)
- path patterns (RE2 with named groups, skip), merge with tags by
  source priority, fallbacks from the file name and folder, name
  normalization and multi-artist split
- scanner: walk, skip unchanged files by size and mtime, ffprobe in 8
  workers with a single catalog writer, renames by content hash keep
  the track, duplicates fold into one track with the best file as
  primary, missing files are kept 30 days
- storage-agent mounts NFS with validated server, export and an option
  whitelist, no shell; test mount counts audio files
- admin API: sources CRUD, connection test, patterns with preview,
  scan, library status, file errors, host folder picker; scheduled
  scans and health checks; mounts are restored on start
- catalog API: artist, artist albums, album with tracks, track
- api image is alpine with ffmpeg for ffprobe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:48:37 +03:00

178 lines
5.0 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
}
// 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"),
}
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))
}