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) } }