feat(library): fingerprint every new or changed file — M400 #3905-#3907
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m28s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m28s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
Two identities per track, because they answer different questions: - audio_stream_sha256: SHA-256 of the ENCODED audio packets (ffmpeg -map 0:a -c:a copy -f hash). Equal means identical audio whatever the tags say. Measured against the #3885 pair: the two WWW files hash identically here and differently as whole files. Packets rather than decoded samples, so an ffmpeg upgrade cannot silently change every stored hash, and nothing is decoded. - chromaprint: fpcalc -raw -signed. The same recording at another bitrate or codec, for the acoustic tier. fpcalc ships in the image (libchromaprint-tools); shelled out because CGO_ENABLED=0 rules out bindings. Stored in a track_fingerprints table rather than on tracks: eight queries read tracks with SELECT *, including album pages, search and the Subsonic surface, and a ~4 KB array there would be de-TOASTed on every one of them. The scan fingerprints only bytes it has not seen (a new path, or mtime past the row's). A tag-repair pass leaves fingerprints alone, and unchanged files with no fingerprint are the backfill's job (#3908). Folding that into the skip check would re-decode the whole library on the first scan after upgrade and push a sync change per track. A failure that says nothing about the file (timeout, cancelled scan, tool not installed) is never stored, and on changed bytes it removes the old row. A tool that rejects the file stores NULL at the current version, so the backfill does not retry it every boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: fingerprints.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteTrackFingerprint = `-- name: DeleteTrackFingerprint :exec
|
||||
DELETE FROM track_fingerprints WHERE track_id = $1
|
||||
`
|
||||
|
||||
// A file changed but could not be fingerprinted, for a reason unrelated to the
|
||||
// file. The stored row describes the OLD bytes, so it goes and the backfill
|
||||
// re-derives it — nothing may keep trusting a stale identity.
|
||||
func (q *Queries) DeleteTrackFingerprint(ctx context.Context, trackID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTrackFingerprint, trackID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertTrackFingerprint = `-- name: UpsertTrackFingerprint :exec
|
||||
INSERT INTO track_fingerprints (
|
||||
track_id, audio_stream_sha256, chromaprint, fingerprint_version
|
||||
) VALUES (
|
||||
$1, $2, $3,
|
||||
$4
|
||||
)
|
||||
ON CONFLICT (track_id) DO UPDATE SET
|
||||
audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
|
||||
chromaprint = EXCLUDED.chromaprint,
|
||||
fingerprint_version = EXCLUDED.fingerprint_version,
|
||||
computed_at = now()
|
||||
`
|
||||
|
||||
type UpsertTrackFingerprintParams struct {
|
||||
TrackID pgtype.UUID
|
||||
AudioStreamSha256 []byte
|
||||
Chromaprint []int32
|
||||
FingerprintVersion int16
|
||||
}
|
||||
|
||||
// Written whenever a track's fingerprint is derived: by the scan when a file is
|
||||
// new or its bytes changed, and by the backfill (#3908) for rows derived by an
|
||||
// older method. Replaces the row wholesale — a fingerprint of the old bytes has
|
||||
// no standing once the file has changed.
|
||||
func (q *Queries) UpsertTrackFingerprint(ctx context.Context, arg UpsertTrackFingerprintParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertTrackFingerprint,
|
||||
arg.TrackID,
|
||||
arg.AudioStreamSha256,
|
||||
arg.Chromaprint,
|
||||
arg.FingerprintVersion,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -667,6 +667,14 @@ type Track struct {
|
||||
MissingSince pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TrackFingerprint struct {
|
||||
TrackID pgtype.UUID
|
||||
AudioStreamSha256 []byte
|
||||
Chromaprint []int32
|
||||
FingerprintVersion int16
|
||||
ComputedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TrackSimilarity struct {
|
||||
TrackAID pgtype.UUID
|
||||
TrackBID pgtype.UUID
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE track_fingerprints;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 0058_track_fingerprints.up.sql — an acoustic identity per track (Scribe
|
||||
-- milestone #400: #3905, #3906).
|
||||
--
|
||||
-- A table of its own rather than columns on tracks, for the hot path's sake:
|
||||
-- tracks is read with SELECT * by eight queries, among them ListTracksByAlbum,
|
||||
-- SearchTracks and GetTracksByIDs — album pages, search, the Subsonic surface.
|
||||
-- A ~4 KB chromaprint column on tracks would be de-TOASTed on every one of
|
||||
-- those reads to carry a value only the duplicate sweep ever looks at.
|
||||
--
|
||||
-- What a row means, which the backfill depends on:
|
||||
-- no row never fingerprinted
|
||||
-- fingerprint_version < current derived by an older method; re-derive it
|
||||
-- fingerprint_version = current attempted; a NULL value means that tool
|
||||
-- failed on this file, and it is not retried
|
||||
-- until the file changes
|
||||
-- A failure that says nothing about the file — a timeout, a cancelled scan, a
|
||||
-- missing binary — writes no row at all, so the backfill tries again.
|
||||
CREATE TABLE track_fingerprints (
|
||||
-- CASCADE is right here, unlike for the likes and play history M400's
|
||||
-- merge has to carry across: a fingerprint describes one file's bytes and
|
||||
-- means nothing once that file's row is gone.
|
||||
track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
|
||||
-- SHA-256 of the ENCODED audio packets (ffmpeg -c:a copy -f hash), not of
|
||||
-- decoded samples. internal/library/fingerprint.go says why.
|
||||
audio_stream_sha256 bytea
|
||||
CHECK (audio_stream_sha256 IS NULL OR octet_length(audio_stream_sha256) = 32),
|
||||
-- fpcalc -raw -signed: the same 32 bits per item, stored signed because
|
||||
-- integer is.
|
||||
chromaprint integer[],
|
||||
fingerprint_version smallint NOT NULL,
|
||||
computed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The exact duplicate tier is an equality match on this column. Partial
|
||||
-- because a NULL is never looked up — it only means the hash was not taken.
|
||||
CREATE INDEX track_fingerprints_audio_stream_sha256
|
||||
ON track_fingerprints (audio_stream_sha256)
|
||||
WHERE audio_stream_sha256 IS NOT NULL;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- name: UpsertTrackFingerprint :exec
|
||||
-- Written whenever a track's fingerprint is derived: by the scan when a file is
|
||||
-- new or its bytes changed, and by the backfill (#3908) for rows derived by an
|
||||
-- older method. Replaces the row wholesale — a fingerprint of the old bytes has
|
||||
-- no standing once the file has changed.
|
||||
INSERT INTO track_fingerprints (
|
||||
track_id, audio_stream_sha256, chromaprint, fingerprint_version
|
||||
) VALUES (
|
||||
sqlc.arg(track_id), sqlc.narg(audio_stream_sha256), sqlc.narg(chromaprint),
|
||||
sqlc.arg(fingerprint_version)
|
||||
)
|
||||
ON CONFLICT (track_id) DO UPDATE SET
|
||||
audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
|
||||
chromaprint = EXCLUDED.chromaprint,
|
||||
fingerprint_version = EXCLUDED.fingerprint_version,
|
||||
computed_at = now();
|
||||
|
||||
-- name: DeleteTrackFingerprint :exec
|
||||
-- A file changed but could not be fingerprinted, for a reason unrelated to the
|
||||
-- file. The stored row describes the OLD bytes, so it goes and the backfill
|
||||
-- re-derives it — nothing may keep trusting a stale identity.
|
||||
DELETE FROM track_fingerprints WHERE track_id = $1;
|
||||
Reference in New Issue
Block a user