Files
pipu-music/services/api/internal/library/fingerprint.go
T
mrleo1nidandClaude Opus 5.5 9b50038bb4
ci / api / api (push) Successful in 27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 58s
feat(api): SHA-256 and Chromaprint fingerprints for the library (M5.5)
A background Fingerprinter runs after every scan and at startup, one file
at a time, filling media_files.sha256 and track_fingerprints (fpcalc raw,
first 120 s). Without fpcalc only SHA-256 is computed and files stay
queued for Chromaprint. Changing a file's content resets its prints.
Similarity() compares raw prints with a ±80-point alignment search; it is
the basis for duplicate detection in the upload queue.

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

252 lines
7.7 KiB
Go

package library
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/bits"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// Отпечатки файлов библиотеки (docs/uploads.md#4-проверка-на-дубли): полный SHA-256 ловит точную копию,
// Chromaprint — тот же трек в другом формате / битрейте. Считаются фоном после скана, по одному файлу:
// задача низкого приоритета (~0.3 с на трек), чтобы не мешать стриму.
// FingerprintSeconds — сколько начала трека берёт fpcalc (его значение по умолчанию).
const FingerprintSeconds = 120
// Fingerprint — raw-отпечаток Chromaprint.
type Fingerprint struct {
DurationMs int
Points []uint32
}
// Prefix — первые 64 бита отпечатка: грубый индекс для отбора кандидатов.
func (f Fingerprint) Prefix() int64 {
var p uint64
if len(f.Points) > 0 {
p = uint64(f.Points[0]) << 32
}
if len(f.Points) > 1 {
p |= uint64(f.Points[1])
}
return int64(p)
}
// Similarity — доля совпадающих бит лучшего выравнивания двух отпечатков (0..1). Сдвиг до maxShift точек
// (≈ 0.124 с на точку) прощает тишину в начале и разную длину вступления у разных изданий.
func Similarity(a, b []uint32) float64 {
const maxShift = 80
best := 0.0
for shift := -maxShift; shift <= maxShift; shift++ {
var n, diff int
for i := range a {
j := i + shift
if j < 0 || j >= len(b) {
continue
}
n++
diff += bits.OnesCount32(a[i] ^ b[j])
}
// Слишком короткое перекрытие (меньше ~10 с) не считается.
if n < 80 {
continue
}
if s := 1 - float64(diff)/float64(32*n); s > best {
best = s
}
}
return best
}
// SameRecording — порог «тот же трек»: у разных записей совпадает ~50% бит (случайные), у перекодирования — 85%+.
const SameRecording = 0.75
// FPCalc — Chromaprint через внешний fpcalc. Путь передаётся отдельным аргументом, без shell.
type FPCalc struct {
Bin string
Timeout time.Duration
}
// ErrNoFPCalc — fpcalc не установлен: отпечатки Chromaprint не считаются.
var ErrNoFPCalc = errors.New("library: fpcalc not found")
// Fingerprint считает raw-отпечаток файла.
func (f FPCalc) Fingerprint(ctx context.Context, absPath string) (Fingerprint, error) {
bin := f.Bin
if bin == "" {
bin = "fpcalc"
}
if f.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, f.Timeout)
defer cancel()
}
out, err := exec.CommandContext(ctx, bin, "-raw", "-json", "-length", fmt.Sprint(FingerprintSeconds), "--", absPath).Output()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return Fingerprint{}, ErrNoFPCalc
}
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return Fingerprint{}, fmt.Errorf("fpcalc: %s", strings.TrimSpace(string(ee.Stderr)))
}
return Fingerprint{}, fmt.Errorf("fpcalc: %w", err)
}
return parseFPCalc(out)
}
func parseFPCalc(raw []byte) (Fingerprint, error) {
var o struct {
Duration float64 `json:"duration"`
Fingerprint []uint32 `json:"fingerprint"`
}
if err := json.Unmarshal(raw, &o); err != nil {
return Fingerprint{}, fmt.Errorf("fpcalc: decode: %w", err)
}
if len(o.Fingerprint) == 0 {
return Fingerprint{}, errors.New("fpcalc: empty fingerprint")
}
return Fingerprint{DurationMs: int(o.Duration * 1000), Points: o.Fingerprint}, nil
}
// FileSHA256 — полный SHA-256 файла.
func FileSHA256(abs string) ([]byte, error) {
f, err := os.Open(abs)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// Fingerprinter считает SHA-256 и Chromaprint для файлов библиотеки без отпечатков.
// Без fpcalc работает в режиме «только SHA-256», а файлы остаются в очереди на Chromaprint до его установки.
type Fingerprinter struct {
store *Store
calc FPCalc
rootOf func(ctx context.Context, sourceID uuid.UUID) (string, error)
log *slog.Logger
warned bool
mu sync.Mutex
running bool
again bool
}
// NewFingerprinter создаёт обработчик; rootOf — каталог источника в файловой системе api.
func NewFingerprinter(store *Store, calc FPCalc, rootOf func(ctx context.Context, sourceID uuid.UUID) (string, error),
log *slog.Logger) *Fingerprinter {
return &Fingerprinter{store: store, calc: calc, rootOf: rootOf, log: log}
}
// Trigger запускает проход в фоне; если проход уже идёт — после него будет ещё один (новые файлы скана).
func (fp *Fingerprinter) Trigger(ctx context.Context) {
fp.mu.Lock()
defer fp.mu.Unlock()
if fp.running {
fp.again = true
return
}
fp.running = true
go func() {
for {
fp.Run(ctx)
fp.mu.Lock()
if !fp.again || ctx.Err() != nil {
fp.running = false
fp.mu.Unlock()
return
}
fp.again = false
fp.mu.Unlock()
}
}()
}
func (fp *Fingerprinter) chromaprint() bool {
bin := fp.calc.Bin
if bin == "" {
bin = "fpcalc"
}
if _, err := exec.LookPath(bin); err != nil {
if !fp.warned {
fp.log.Warn("fpcalc is not installed: only exact (SHA-256) duplicates will be detected")
fp.warned = true
}
return false
}
return true
}
// Run — один проход по файлам без отпечатков (смонтированные источники, курсор по id). Файл с ошибкой
// чтения в этом проходе пропускается; следующий скан или перезапуск попробует снова.
func (fp *Fingerprinter) Run(ctx context.Context) {
withFP := fp.chromaprint()
roots := map[uuid.UUID]string{}
var after uuid.UUID
for ctx.Err() == nil {
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
if err != nil {
fp.log.Error("fingerprints: list files", "err", err)
return
}
if len(todo) == 0 {
return
}
for _, f := range todo {
if ctx.Err() != nil {
return
}
after = f.ID
root, ok := roots[f.SourceID]
if !ok {
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
fp.log.Warn("fingerprints: source root", "source_id", f.SourceID, "err", err)
continue
}
roots[f.SourceID] = root
}
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
}
}
}
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
sum := f.SHA256
if sum == nil {
var err error
if sum, err = FileSHA256(abs); err != nil {
fp.log.Warn("fingerprints: sha256", "file_id", f.ID, "err", err)
return
}
}
var print *Fingerprint
if withFP {
res, err := fp.calc.Fingerprint(ctx, abs)
if err != nil {
// Битый файл: отмечаем обработанным без отпечатка, чтобы не пересчитывать каждый раз.
fp.log.Warn("fingerprints: fpcalc", "file_id", f.ID, "err", err)
} else {
print = &res
}
}
if err := fp.store.SaveFingerprint(ctx, f.ID, sum, print, withFP); err != nil {
fp.log.Error("fingerprints: save", "file_id", f.ID, "err", err)
}
}