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
306 lines
9.4 KiB
Go
306 lines
9.4 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
|
|
}
|
|
|
|
// EncodedFingerprint returns compressed Chromaprint for AcoustID. Use ffprobe for full duration.
|
|
func (f FPCalc) EncodedFingerprint(ctx context.Context, absPath string) (string, error) {
|
|
bin := f.Bin
|
|
if bin == "" {
|
|
bin = "fpcalc"
|
|
}
|
|
timeout := f.Timeout
|
|
if timeout <= 0 {
|
|
timeout = time.Minute
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, bin, "-json", "-length", fmt.Sprint(FingerprintSeconds), "--", absPath).Output()
|
|
if err != nil {
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return "", ErrNoFPCalc
|
|
}
|
|
return "", fmt.Errorf("fpcalc: encoded fingerprint: %w", err)
|
|
}
|
|
var result struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
}
|
|
if err = json.Unmarshal(out, &result); err != nil {
|
|
return "", err
|
|
}
|
|
if len(result.Fingerprint) == 0 || len(result.Fingerprint) > 65536 || strings.ContainsAny(result.Fingerprint, " \t\r\n") {
|
|
return "", errors.New("fpcalc: invalid encoded fingerprint")
|
|
}
|
|
return result.Fingerprint, nil
|
|
}
|
|
|
|
// 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{}
|
|
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
|
|
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
|
|
if unavailable[f.SourceID] {
|
|
continue
|
|
}
|
|
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
|
|
if empty, err := dirEmpty(root); empty || err != nil {
|
|
fp.log.Warn("fingerprints: source folder is empty or unreadable, skipped — is the disk mounted?",
|
|
"source_id", f.SourceID, "root", root, "err", err)
|
|
unavailable[f.SourceID] = true
|
|
continue
|
|
}
|
|
}
|
|
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
|
|
}
|
|
}
|
|
}
|
|
|
|
func dirEmpty(dir string) (bool, error) {
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = d.Close() }()
|
|
names, err := d.Readdirnames(1)
|
|
if errors.Is(err, io.EOF) {
|
|
return true, nil
|
|
}
|
|
return len(names) == 0, err
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|