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
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:
1 parent
ab092def54
commit
c583991d14
24 files changed
+7467
-84
No files matched your search
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user