Acoustic fingerprints at ingest, a delete that can't lose history, and a reproducible candidate draw #132

Merged
bvandeusen merged 5 commits from dev into main 2026-09-11 14:43:00 -04:00
5 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 71d4335584 docs(readme): the music mount is writable — Minstrel deletes when asked
test-go / test (push) Successful in 1m8s
test-go / integration (push) Successful in 4m10s
release / Build signed APK (releases and dev) (push) Successful in 5m23s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Skipped
The quickstart mounted the library :ro and promised "Minstrel never
writes to your library". That stopped being true long before #3918:
quarantine's Delete file removes files, and under :ro it failed. The
operator has accepted delete ownership (Scribe note #3926).

The quickstart now mounts it writable and says exactly what Minstrel
writes: it deletes a file when an admin asks, and never moves, renames
or retags. It notes that uid 1000 needs write access, and that :ro
still works, with deletes refusing and explaining why.

Reorganising and tag writes stay out, pending whether Minstrel absorbs
Lidarr's role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:34:28 -04:00
bvandeusenandClaude Opus 5 702b48ce36 fix(lidarrquarantine): pass dataDir at the four stub-client test constructors
d7a8e5f3 added a dataDir parameter to NewService and updated the 13
call sites spelled NewService(pool, lidarrconfig.New(pool), nil). Four
more build their client from a Lidarr stub, NewService(pool, cfg,
clientFn), and were missed, so the package's tests did not compile and
run 6495 failed both go vet and the integration build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:34:28 -04:00
bvandeusenandClaude Opus 5 d7a8e5f300 fix(library): a track delete that cannot remove its file deletes nothing — #3918
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.

One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.

Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.

The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.

DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:23:01 -04:00
bvandeusenandClaude Opus 5 cba77a5187 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
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
2026-09-11 13:21:15 -04:00
bvandeusenandClaude Opus 5 eff3d88931 fix(recommendation): make the candidate draw reproducible, not accidentally so
test-go / test (push) Successful in 1m18s
test-go / integration (push) Successful in 4m52s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Build + push container image (push) Successful in 2m5s
release / Verify release artifacts (tag releases only) (push) Skipped
Four arms of the candidate query ended in a bare `ORDER BY random()` with no
seed: similar_artists, likes_overlap, coplay_artists and random_fill.

Such an arm returns a STABLE set only while its LIMIT exceeds the rows
eligible for it — at that point it returns all of them and the order stops
mattering, because scoreAndSortCandidates sorts by track id before drawing
jitter. Below that threshold it returns a random SUBSET, and two builds on
the same day draw different ones.

So daily determinism held BY ACCIDENT, and only for libraries smaller than
the limits. Any real library is larger, which means same-day rebuilds have
been producing different mixes since those arms were written — invisible,
because a mix that changes after a refresh looks like a feature rather than
a broken promise.

Found by breaking it: cutting RandomFill to 10 while tuning Songs-like
turned TestBuildSystemPlaylists_DailyNonceDeterminism red. That test seeds
~20 tracks against a default RandomFill of 30, so its determinism came from
the limit exceeding the library, not from the code being right. It is now a
real guard.

The arms order by md5(id || $12) instead. The CALLER decides what that
means, which is the point: system mixes pass a per-(user, day) seed and get
the determinism they promise, radio passes a fresh value per request and
keeps varying, which is what a radio should do. Same shape the browse
queries in this file already use (`md5(id::text || current_date::text)`) —
existing idiom, not a new one.

This also unblocks the trim that #3881 wanted and could not have. Shrinking
a randomly-ordered arm was what broke membership; a seeded one takes a
smaller but REPRODUCIBLE slice. Songs-like's seed-independent share drops
from 29% to 12%, which was the original intent before determinism forced it
back to 20%.

TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms is DELETED rather than
kept passing. It existed to stop anyone trimming those arms while the
ordering was broken; the ordering is fixed, so the constraint is gone and a
guard enforcing it would now forbid correct code.

Was filed as blocked on tooling. It was not: `make generate-go` runs sqlc as
a pinned Go tool and is the same path CI takes.

One thing worth knowing for next time: three files in internal/db/dbq are
owned by root, left by `make generate` running sqlc in Docker. sqlc errored
on the first it could not write. They are untouched by this change and the
regeneration of recommendation.sql.go completed, but `make generate` will
keep failing until they are chowned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 08:44:33 -04:00