Port upstream PR #357: rune-based track matching + shared LibraryIndex - #32
Conversation
…ibraryIndex Ports joestump#357 (commits b04c2df + e4d4ed7, review polish included), implementing joestump#330. - similarity() now measures both Levenshtein distance and max length in RUNES; the fork's version computed a rune-based distance but divided by a byte-based max length, inflating similarity 2-3x for non-ASCII (CJK/Cyrillic) titles and letting wrong tracks clear the fuzzy threshold (SPEC track-matching REQ-TM-031, ADR-0014) - New exported LibraryIndex/NormalizedCandidate: the user's library is loaded and normalized once per sync tick via LoadLibraryIndex and shared across playlists via MatchTracksWithIndex; MatchTracks keeps its old signature for single-playlist callers - SyncAllEnabledPlaylists loads the index once per tick; a tick-level load failure falls back to per-playlist loading so each playlist still reaches handleSyncError (sync_status=error, SyncEvent, UI notification per REQ-PLSYNC-060) - Preserves the fork's regex-based suffix normalization, per-strategy metric.track_match metrics (ADR-0019), and artist-edge user scoping - Config default playlist_sync.min_match_confidence was already 0.7 in the fork; upstream's config change reduced to a comment no-op - Tests: rune-unit similarity/levenshtein cases, CJK/Cyrillic no-false-match regressions, suffix-variant matches, query-counting proof that per-tick library queries no longer scale with playlist count, shared-index vs legacy benchmarks Known follow-up (out of scope, upstream deferred to story joestump#340): the same rune/byte mismatch exists in internal/vibes/generator.go similarity(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
joestump-agent
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE (submitted as COMMENT — GitHub rejects APPROVE because the review identity is the PR author; zero blocking findings.)
Adversarial review of the rune-similarity fix + shared LibraryIndex port (upstream joestump#357 / issue joestump#330). I tried to break the rune math, the per-tick index sharing, user scoping, the failure fallback, and the new tests. No blocking findings; three non-blocking notes.
Verification on the branch: make generate clean, go build ./... clean, go vet ./... clean, gofmt -l . clean, go test ./... all green (services package re-run with -count=1).
Findings
-
NON-BLOCKING —
internal/services/track_matcher.go:160-163:MatchTracksWithIndexpanics on a nil index. The scheduler's per-user goroutines incmd/server/main.go:261-278have norecover(), so a future caller that passes nil (e.g. someone forgetting the nil-guard thatsyncPlaylistToNavidromehas atplaylist_sync.go:200) crashes the entire server process from a background tick, not just that user's sync. All current callers guard correctly, so this is a latent-API hazard, not a live bug. An error return (or documented invariant plus the panic) would be safer for the "other fuzzy-matching consumers" the exported type is advertised to. -
NON-BLOCKING —
internal/services/playlist_sync.go:457-463: the tick-levelLoadLibraryIndexfailure fallback (nil index → per-playlist load →handleSyncError→ sync_status=error + SyncEvent + notification) is correct by code reading, but has no test. A test that injects a failing driver into the matcher and asserts each playlist ends insync_status=errorwith aplaylist_sync_failedSyncEvent would pin the REQ-PLSYNC-060 behavior the comment promises. -
NON-BLOCKING —
internal/services/playlist_sync.go:449-463: behavior change worth stating in the PR description: within one tick, all playlists now match against a library snapshot taken at tick start. Tracks gainingnavidrome_idmid-tick (concurrent Navidrome library sync) won't match until the next tick, whereas the old code reloaded fresh per playlist. Bounded by the sync interval and inherent to issue joestump#330's design, so acceptable — just no longer "always fresh".
Probed and could not break
- Rune math, hand-computed: library "夜に駆ける"/YOASOBI vs source "群青"/YOASOBI — old code: title 1−5/15 = 0.667 (rune distance / byte maxLen), weighted 0.6·0.667 + 0.4·1.0 = 0.8 ≥ 0.7 → false match. New code: title 0.0, weighted 0.4 → unmatched. Similarity is now provably in [0,1] (rune distance ≤ rune maxLen); ASCII scores are bit-identical to before (
TestSimilarity_ASCII_UnchangedByRuneFix). Both-empty → 1.0 viaslices.Equal, one-empty → 0.0, no division by zero. - Test quality, mechanically verified: I locally re-introduced the byte-based
maxLen(len(string(a))) and re-ran the new tests —TestSimilarity_CJK_DissimilarTitlesScoreZero,TestSimilarity_Cyrillic_DissimilarTitlesScoreLow,TestTrackMatcher_CJK_DissimilarTitlesDoNotMatch, andTestTrackMatcher_Cyrillic_DissimilarTitlesDoNotMatchall FAIL under the reverted math and pass on the branch. The tests genuinely pin the fix. - User scoping:
SyncAllEnabledPlaylistsonly queries playlists withHasUserWith(user.ID(userID))(playlist_sync.go:424-431), so the shared index user always equals the playlist owner; the belt-and-suspenders guardlibraryIndex == nil || libraryIndex.UserID != u.IDatplaylist_sync.go:200reloads on any mismatch.LoadLibraryIndexkeeps the Track→Artist→User edge filter (track_matcher.go:87-93). I could not construct a cross-user leak. - Concurrency:
runPerUserTickruns users in parallel but eachSyncAllEnabledPlaylistscall builds its own index and syncs its playlists sequentially; the index is never mutated afterLoadLibraryIndexreturns. - Fork regressions: regex suffix normalization (
featParenRe/featBareRe/yearRemasterRe) untouched;metric.track_matchemission preserved for isrc/exact/fuzzy/none paths inMatchTracksWithIndex; the removedNavidromeID == nilskip infindBestFuzzyMatchis safe becauseLoadLibraryIndexfiltersNavidromeIDNotNilat the query. - Query-count test:
loadCostis measured empirically then asserted asplaylists*loadCostfor the legacy path — robust to eager-load query-shape changes. - Config:
vibes.min_match_confidencechange is comment-only (value stays 0.7, now accurately described as equal to the playlist-sync default).
Generated by Claude Code
…test Non-blocking review items from the adversarial review of the joestump#357 port: - MatchTracksWithIndex: document the non-nil index INVARIANT in the doc comment (callers must guard, as syncPlaylistToNavidrome does); an unguarded nil from a future background-goroutine caller would panic and crash the server. Signature unchanged. - Add TestPlaylistSyncService_SyncAllEnabledPlaylists_LibraryIndexLoadFailure: injects a driver that fails only "tracks"-table queries so the tick-level LoadLibraryIndex in SyncAllEnabledPlaylists fails, then asserts the tick does not abort — each due playlist falls back to a per-playlist load (query-count proof: 1 tick + 1 per playlist) and still reaches handleSyncError with sync_status=error and a playlist_sync_failed SyncEvent per playlist (REQ-PLSYNC-060). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports upstream joestump#357 (commits
b04c2df+ review-polishe4d4ed7) into the fork, implementing joestump#330.What was ported
similarity()— both the Levenshtein distance and the max-length denominator are now measured in runes. The fork's version computed a rune-based distance but divided by a byte-basedlen(string), inflating similarity 2-3x for non-ASCII (CJK/Cyrillic) titles and letting wrong tracks clear the 0.7 fuzzy threshold. Governing: SPEC track-matching REQ-TM-031, ADR-0014.LibraryIndex/NormalizedCandidate— the user's library is loaded and normalized once per sync tick viaLoadLibraryIndexand shared across playlists viaMatchTracksWithIndex(no I/O).MatchTrackskeeps its old signature for single-playlist callers.SyncAllEnabledPlaylistsbuilds the index once per tick. A tick-levelLoadLibraryIndexfailure does not abort the tick: it falls back to per-playlist loading so each playlist still reaches the fork'shandleSyncErrorpath (sync_status=error, SyncEvent audit log, UI notification per REQ-PLSYNC-060).syncPlaylistToNavidromealso reloads defensively if the supplied index was built for a different user.track_matcher_internal_test.go(rune-unit similarity/levenshtein cases, CJK/Cyrillic low-similarity pins, suffix-variant normalization) andtrack_matcher_index_test.go(query-counting proof that per-tick library queries don't scale with playlist count, CJK/Cyrillic no-false-match regressions, shared-index vs legacy benchmarks).Intentional behavior change: per-tick library snapshot
Within a single sync tick, all playlists now match against a library snapshot taken at tick start. Previously each playlist reloaded the library fresh, so a track that gained a
navidrome_idmid-tick (e.g. a concurrent Navidrome sync finishing) could be picked up by later playlists in the same tick. With the shared index, such tracks are picked up on the next tick instead. This staleness is bounded by one tick interval (default 1h), is the deliberate trade-off behind the per-tick performance win in issue joestump#330, and matches upstream's semantics. The index is read-only after construction and the playlist loop is sequential, so there is no concurrent-mutation concern.Gap analysis (what the fork already had)
playlist_sync.min_match_confidencealready defaulted to 0.7 in the fork (upstream was aligning 0.8 → 0.7), so that part reduced to a comment no-op; the fork's governing comment was kept. One adjacent comment fix was kept: the fork'svibes.min_match_confidencecomment claimed 0.7 was "lower than playlist sync", which was stale — both are 0.7.featParenRe/featBareRe/yearRemasterRe), per-strategymetric.track_matchstructured metrics (ADR-0019, REQ MATCH-001/002/003), and library scoping through the artist→user edge.Review findings on the upstream change
MatchTracksWithIndexnil-index panic — evaluated, kept, now documented. The method performs no I/O and cannot fail; a nil index is a programmer contract violation that would nil-deref panic atidx.Size()anyway. The explicit panic just upgrades the message. All in-repo callers guarantee non-nil (syncPlaylistToNavidromeloads on demand when the shared index is nil or for the wrong user). Per adversarial review feedback, the non-nil INVARIANT is now stated in the method's doc comment — including the warning that an unguarded caller in a background goroutine would crash the server — without changing the signature.levenshteinoperates on runes — confirmed. After the port it takes[]runeparameters and all indexing/comparison is in rune units;similaritydivides rune distance by rune max length. (Pre-port, the fork'slevenshteinalready converted to runes internally, butsimilarityused byte lengths for the denominator — the exact mismatch Fix track-matching correctness (rune/byte) and per-tick performance joestump/spotter#330 describes.)NavidromeIDNotNilquery, so the removed per-candidate nil check was genuinely redundant.internal/vibes/generator.gosimilarity(). Upstream deliberately deferred consolidating the vibes matcher ontoNormalizedCandidateto story Deduplicate mixtape generation; unify vibes fuzzy matching with track matcher joestump/spotter#340; recommend a follow-up issue in this fork.Post-review additions (non-blocking items from the adversarial review)
MatchTracksWithIndexnon-nil index invariant (see finding 1).TestPlaylistSyncService_SyncAllEnabledPlaylists_LibraryIndexLoadFailure: injects an Ent driver that fails onlytracks-table queries so the tick-levelLoadLibraryIndexfails, then asserts the tick does not abort — the fallback issues exactly one per-playlist load per playlist (query-count proof: 1 tick-level + 2 per-playlist failed loads) and every due playlist ends withsync_status=error, itssync_errorrecording the library-index failure, and aplaylist_sync_failedSyncEvent (REQ-PLSYNC-060).Test results
Benchmarks (5 iterations, 500-track library, 25 fuzzy source tracks):
🤖 Generated with Claude Code