feat(api): SHA-256 and Chromaprint fingerprints for the library (M5.5)
ci / api / api (push) Successful in 27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 58s

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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:37:05 +03:00
1 parent ed063d3763
commit 9b50038bb4
9 files changed
+406 -6

No files matched your search

+61 -1
View File
@@ -271,7 +271,12 @@ func (st *Store) SaveFile(ctx context.Context, sourceID, id uuid.UUID, d FileDat
mtime = EXCLUDED.mtime, content_hash = EXCLUDED.content_hash, format = EXCLUDED.format,
codec = EXCLUDED.codec, bitrate = EXCLUDED.bitrate, sample_rate = EXCLUDED.sample_rate,
bit_depth = EXCLUDED.bit_depth, channels = EXCLUDED.channels, duration_ms = EXCLUDED.duration_ms,
track_id = EXCLUDED.track_id, scan_error = EXCLUDED.scan_error, missing_since = NULL`,
track_id = EXCLUDED.track_id, scan_error = EXCLUDED.scan_error, missing_since = NULL,
-- Содержимое изменилось — отпечатки посчитаются заново.
sha256 = CASE WHEN media_files.content_hash IS DISTINCT FROM EXCLUDED.content_hash
OR media_files.size_bytes <> EXCLUDED.size_bytes THEN NULL ELSE media_files.sha256 END,
fingerprinted_at = CASE WHEN media_files.content_hash IS DISTINCT FROM EXCLUDED.content_hash
OR media_files.size_bytes <> EXCLUDED.size_bytes THEN NULL ELSE media_files.fingerprinted_at END`,
id, sourceID, d.RelPath, d.Size, d.Mtime, d.ContentHash, p.Format, p.Codec, p.Bitrate, p.SampleRate,
p.BitDepth, p.Channels, p.DurationMs, trackID, scanErr)
if err != nil {
@@ -623,3 +628,58 @@ func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
}
return m, nil
}
// FingerprintTodo — файл, которому нужны отпечатки.
type FingerprintTodo struct {
ID uuid.UUID
SourceID uuid.UUID
RelPath string
SHA256 []byte // уже посчитан — повторно не читаем
}
// FilesToFingerprint — файлы смонтированных источников без SHA-256 (и без Chromaprint, если withFP), после after.
func (st *Store) FilesToFingerprint(ctx context.Context, withFP bool, after uuid.UUID, limit int) ([]FingerprintTodo, error) {
rows, err := st.pool.Query(ctx, `
SELECT mf.id, mf.source_id, mf.rel_path, mf.sha256
FROM media_files mf
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE mf.missing_since IS NULL AND mf.scan_error IS NULL
AND (mf.sha256 IS NULL OR ($1 AND mf.fingerprinted_at IS NULL)) AND mf.id > $2
ORDER BY mf.id
LIMIT $3`, withFP, after, limit)
if err != nil {
return nil, fmt.Errorf("library: files to fingerprint: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (FingerprintTodo, error) {
var f FingerprintTodo
return f, r.Scan(&f.ID, &f.SourceID, &f.RelPath, &f.SHA256)
})
}
// SaveFingerprint сохраняет SHA-256 и (если есть) отпечаток Chromaprint; chromaprintDone — fpcalc отработал
// (в том числе с ошибкой), повторять не нужно.
func (st *Store) SaveFingerprint(ctx context.Context, fileID uuid.UUID, sum []byte, fp *Fingerprint, chromaprintDone bool) error {
tx, err := st.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("library: save fingerprint: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE media_files SET sha256 = $2, fingerprinted_at = CASE WHEN $3 THEN now() ELSE fingerprinted_at END
WHERE id = $1`, fileID, sum, chromaprintDone); err != nil {
return fmt.Errorf("library: save fingerprint: %w", err)
}
if fp != nil {
points := make([]int32, len(fp.Points))
for i, p := range fp.Points {
points[i] = int32(p)
}
if _, err := tx.Exec(ctx, `
INSERT INTO track_fingerprints (file_id, duration_ms, fingerprint, fp_prefix) VALUES ($1, $2, $3, $4)
ON CONFLICT (file_id) DO UPDATE SET duration_ms = EXCLUDED.duration_ms, fingerprint = EXCLUDED.fingerprint,
fp_prefix = EXCLUDED.fp_prefix`, fileID, fp.DurationMs, points, fp.Prefix()); err != nil {
return fmt.Errorf("library: save fingerprint: %w", err)
}
}
return tx.Commit(ctx)
}