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>
This commit is contained in:
1 parent
ed063d3763
commit
9b50038bb4
9 files changed
+406
-6
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user