feat(api): library sources, scanner and catalog (M2)
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

- 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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:48:37 +03:00
1 parent ab092def54
commit c583991d14
24 files changed
+7467 -84

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
// 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))
}