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
297 lines
11 KiB
Go
297 lines
11 KiB
Go
package library
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Acoustic identity (M400).
|
|
//
|
|
// Two values per track, because they answer different questions:
|
|
//
|
|
// audio_stream_sha256 a SHA-256 of the ENCODED audio packets. Equal means the
|
|
// same audio bytes, whatever the tags or container around
|
|
// them say. No threshold and no false positives — this is
|
|
// what catches two copies of one MP3 that differ only in
|
|
// their ID3 (#3885).
|
|
//
|
|
// chromaprint fpcalc's raw fingerprint. Close means the same
|
|
// recording, even at another bitrate or in another codec
|
|
// — the case an exact hash cannot see.
|
|
//
|
|
// Both shell out, in the shape probeDurationMs already set: a deadline on every
|
|
// call, and a failure that leaves the value unset rather than failing the file.
|
|
// A track with no fingerprint is never a duplicate candidate; it is still a
|
|
// track.
|
|
|
|
// fingerprintTimeout bounds one ffmpeg hash or fpcalc call. Longer than
|
|
// probeTimeout because both read the audio rather than a header: the hash reads
|
|
// every packet and fpcalc decodes up to its -length. 60s leaves room for a large
|
|
// lossless file on a slow network mount; a call needing more is a stall, not a
|
|
// big file.
|
|
const fingerprintTimeout = 60 * time.Second
|
|
|
|
// fingerprintWaitDelay bounds how long Output may keep waiting on the tool's
|
|
// pipes after the deadline has killed it. Without it, a child that left a
|
|
// descendant holding stdout open would block the scan past its own timeout.
|
|
const fingerprintWaitDelay = 5 * time.Second
|
|
|
|
// fingerprintVersion stamps how a track_fingerprints row was derived. Bump it
|
|
// whenever the derivation changes — the hash arguments, fpcalc's flags or its
|
|
// length — and the backfill re-derives every row below it. Fingerprints taken
|
|
// by two methods are not comparable, and nothing else would reveal that the
|
|
// library held a mix.
|
|
const fingerprintVersion int16 = 1
|
|
|
|
// errFingerprintTimeout marks a tool that ran out of time. Distinct from a
|
|
// failed exit because a stall is a fact about the mount, not about the file.
|
|
var errFingerprintTimeout = errors.New("fingerprint tool timed out")
|
|
|
|
// defaultChromaprintLengthSec is how many seconds of audio fpcalc fingerprints.
|
|
// 120 is fpcalc's own default. Fingerprints taken at different lengths are not
|
|
// comparable, so changing this has to re-derive every stored one.
|
|
const defaultChromaprintLengthSec = 120
|
|
|
|
// fpcalcStderrTail caps how much of a failing tool's stderr reaches the log.
|
|
const fpcalcStderrTail = 512
|
|
|
|
// streamHashArgs hashes the encoded audio packets, never decoded samples.
|
|
//
|
|
// -c:a copy is the point, not an optimisation. A decoded hash of a lossy file
|
|
// depends on the decoder's float maths and sample conversion, which can move
|
|
// between ffmpeg releases — so an image upgrade could silently change every
|
|
// stored hash, and yesterday's duplicate would stop matching today's copy.
|
|
// Packet bytes do not move. It is also far cheaper: demux only, no decode.
|
|
//
|
|
// -map 0:a keeps embedded cover art (an attached-picture video stream) out of
|
|
// the hash, so two copies of one recording carrying different art still match.
|
|
func streamHashArgs(path string) []string {
|
|
return []string{
|
|
"-v", "error",
|
|
"-i", path,
|
|
"-map", "0:a",
|
|
"-c:a", "copy",
|
|
"-f", "hash", "-hash", "sha256",
|
|
"-",
|
|
}
|
|
}
|
|
|
|
// fpcalcArgs asks for the raw fingerprint as SIGNED integers.
|
|
//
|
|
// -raw because the matcher compares items bit by bit, which the compressed form
|
|
// cannot do without being unpacked first. -signed because the column is Postgres
|
|
// integer[], which is signed: fpcalc's default prints uint32, and half of those
|
|
// values do not fit. Signed output is the same 32 bits with no reinterpretation
|
|
// step left to get wrong.
|
|
func fpcalcArgs(path string, lengthSec int) []string {
|
|
return []string{
|
|
"-raw", "-signed",
|
|
"-length", strconv.Itoa(lengthSec),
|
|
path,
|
|
}
|
|
}
|
|
|
|
// fingerprintResult is one attempt at both halves of a track's identity. They
|
|
// fail independently: a file ffmpeg can demux may still defeat fpcalc.
|
|
type fingerprintResult struct {
|
|
streamSHA256 []byte
|
|
chromaprint []int32
|
|
hashErr error
|
|
printErr error
|
|
}
|
|
|
|
// computeFingerprint derives both halves for the file at path.
|
|
func computeFingerprint(ctx context.Context, path string) fingerprintResult {
|
|
var r fingerprintResult
|
|
r.streamSHA256, r.hashErr = computeAudioStreamSHA256(ctx, path)
|
|
r.chromaprint, r.printErr = computeChromaprint(ctx, path, defaultChromaprintLengthSec)
|
|
return r
|
|
}
|
|
|
|
// inconclusive reports whether either half failed for a reason that says
|
|
// nothing about the file. Such a result must never be stored: stamped at the
|
|
// current version it would read as "tried, and this file cannot be
|
|
// fingerprinted", and the backfill would never try it again.
|
|
func (r fingerprintResult) inconclusive() bool {
|
|
return isInconclusive(r.hashErr) || isInconclusive(r.printErr)
|
|
}
|
|
|
|
// isInconclusive names the failures that are not a verdict on the file: a
|
|
// stall, a cancelled scan, and a tool that is not installed. The last matters
|
|
// outside the image — a dev binary run without fpcalc on PATH must not stamp
|
|
// every track in the library as unfingerprintable.
|
|
func isInconclusive(err error) bool {
|
|
return errors.Is(err, errFingerprintTimeout) ||
|
|
errors.Is(err, context.Canceled) ||
|
|
errors.Is(err, context.DeadlineExceeded) ||
|
|
errors.Is(err, exec.ErrNotFound)
|
|
}
|
|
|
|
// fingerprintFile runs the scanner's fingerprinter. A Scanner built without New
|
|
// gets the real tools rather than a nil-func panic halfway through a scan.
|
|
func (s *Scanner) fingerprintFile(ctx context.Context, path string) fingerprintResult {
|
|
if s.fingerprint == nil {
|
|
return computeFingerprint(ctx, path)
|
|
}
|
|
return s.fingerprint(ctx, path)
|
|
}
|
|
|
|
// storeFingerprint records one attempt for a track whose bytes are new or have
|
|
// changed. It never fails the scan: a missing fingerprint only keeps a track
|
|
// out of duplicate detection, which is not worth dropping the track over.
|
|
func (s *Scanner) storeFingerprint(
|
|
ctx context.Context, q *dbq.Queries, trackID pgtype.UUID, path string, fp fingerprintResult,
|
|
) {
|
|
if fp.hashErr != nil {
|
|
s.logger.Warn("library scan: audio stream hash failed", "path", path, "err", fp.hashErr)
|
|
}
|
|
if fp.printErr != nil {
|
|
s.logger.Warn("library scan: chromaprint failed", "path", path, "err", fp.printErr)
|
|
}
|
|
if fp.inconclusive() {
|
|
// Any row this track holds describes its PREVIOUS bytes. Drop it and
|
|
// leave the track to the backfill, rather than stamping a failure that
|
|
// says nothing about this file.
|
|
if err := q.DeleteTrackFingerprint(ctx, trackID); err != nil {
|
|
s.logger.Warn("library scan: clearing stale fingerprint failed", "path", path, "err", err)
|
|
}
|
|
return
|
|
}
|
|
// A NULL half here is a verdict — the tool ran and rejected this file — and
|
|
// is stamped at the current version so the backfill does not retry it on
|
|
// every boot. It is retried when the file changes.
|
|
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
|
|
TrackID: trackID,
|
|
AudioStreamSha256: fp.streamSHA256,
|
|
Chromaprint: fp.chromaprint,
|
|
FingerprintVersion: fingerprintVersion,
|
|
}); err != nil {
|
|
s.logger.Warn("library scan: storing fingerprint failed", "path", path, "err", err)
|
|
}
|
|
}
|
|
|
|
// computeAudioStreamSHA256 returns the SHA-256 of the file's encoded audio.
|
|
func computeAudioStreamSHA256(ctx context.Context, path string) ([]byte, error) {
|
|
out, err := runFingerprintTool(ctx, "ffmpeg", streamHashArgs(path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseStreamHash(out)
|
|
}
|
|
|
|
// computeChromaprint returns the raw acoustic fingerprint of the first
|
|
// lengthSec seconds of the file.
|
|
func computeChromaprint(ctx context.Context, path string, lengthSec int) ([]int32, error) {
|
|
out, err := runFingerprintTool(ctx, "fpcalc", fpcalcArgs(path, lengthSec))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseFpcalcRaw(out)
|
|
}
|
|
|
|
// runFingerprintTool runs one tool under fingerprintTimeout.
|
|
//
|
|
// Any non-zero exit is an error, and that deliberately includes fpcalc's exit 3:
|
|
// "reading failed, but here is a fingerprint of what I got". A partial
|
|
// fingerprint of a damaged file is not that file's identity. Stored, it would
|
|
// score against a healthy copy over whatever prefix survived, and could group
|
|
// or fail to group either way. Absent is better than wrong.
|
|
func runFingerprintTool(ctx context.Context, name string, args []string) ([]byte, error) {
|
|
runCtx, cancel := context.WithTimeout(ctx, fingerprintTimeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(runCtx, name, args...)
|
|
cmd.WaitDelay = fingerprintWaitDelay
|
|
out, err := cmd.Output()
|
|
if err == nil {
|
|
return out, nil
|
|
}
|
|
// The caller gave up (a cancelled scan). Report that rather than the
|
|
// signal-killed exit it caused, so it is never mistaken for a verdict on
|
|
// the file.
|
|
if ctx.Err() != nil {
|
|
return nil, fmt.Errorf("%s: %w", name, ctx.Err())
|
|
}
|
|
// Named separately so a stall reads as a stall, not as a crash.
|
|
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
|
return nil, fmt.Errorf("%s: no result within %s: %w", name, fingerprintTimeout, errFingerprintTimeout)
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return nil, fmt.Errorf("%s exited %d: %s", name, exitErr.ExitCode(), stderrTail(exitErr.Stderr))
|
|
}
|
|
return nil, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
|
|
// stderrTail keeps the END of a failing tool's stderr. ffmpeg and fpcalc print
|
|
// the actual reason last, after any banner or per-frame warnings, so a cap that
|
|
// kept the head would log the noise and drop the cause.
|
|
func stderrTail(stderr []byte) []byte {
|
|
stderr = bytes.TrimSpace(stderr)
|
|
if len(stderr) > fpcalcStderrTail {
|
|
stderr = stderr[len(stderr)-fpcalcStderrTail:]
|
|
}
|
|
return stderr
|
|
}
|
|
|
|
// parseStreamHash reads the ffmpeg hash muxer's "SHA256=<hex>" line.
|
|
func parseStreamHash(out []byte) ([]byte, error) {
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
hexed, ok := strings.CutPrefix(strings.TrimSpace(line), "SHA256=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
sum, err := hex.DecodeString(hexed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stream hash %q: %w", hexed, err)
|
|
}
|
|
if len(sum) != sha256.Size {
|
|
return nil, fmt.Errorf("stream hash is %d bytes, want %d", len(sum), sha256.Size)
|
|
}
|
|
return sum, nil
|
|
}
|
|
return nil, errors.New("ffmpeg printed no SHA256= line")
|
|
}
|
|
|
|
// parseFpcalcRaw reads fpcalc's text output:
|
|
//
|
|
// DURATION=<seconds>
|
|
// FINGERPRINT=<int32>,<int32>,...
|
|
func parseFpcalcRaw(out []byte) ([]int32, error) {
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
list, ok := strings.CutPrefix(strings.TrimSpace(line), "FINGERPRINT=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
if list == "" {
|
|
return nil, errors.New("fpcalc returned an empty fingerprint")
|
|
}
|
|
items := strings.Split(list, ",")
|
|
fp := make([]int32, len(items))
|
|
for i, item := range items {
|
|
// ParseInt at 32 bits, not ParseUint: a value past int32 means the
|
|
// output was unsigned — -signed went missing from the invocation —
|
|
// and nothing downstream would reinterpret it. Refuse it here.
|
|
v, err := strconv.ParseInt(item, 10, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fingerprint item %d %q: %w", i, item, err)
|
|
}
|
|
fp[i] = int32(v)
|
|
}
|
|
return fp, nil
|
|
}
|
|
return nil, errors.New("fpcalc printed no FINGERPRINT= line")
|
|
}
|