feat: autotag scoring overhaul, dump-based explore index, and lyrics search
Consolidates in-progress work across autotag, explore, and library: - autotag: beets/Picard-informed scoring engine — ID-first matching, VA handling, recommendation tiers, and a merged distance/rank cascade, with an eval harness for regression tracking. - explore: offline MusicBrainz dump import/incremental refresh replaces the legacy tier crawl; index-first local search with fuzzy matching and a dedicated ranker; disk-free guards for dump downloads. - library: artist-credit extraction and matching. - lyrics: owned-library lyric search (FTS) with LRCLIB backfill. Also: rewrite README to be user-focused, and migrate upstream to git.ljones.me/yonlu/yellowjacket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
# Notes
|
||||
|
||||
Miscellaneous things worth knowing that aren't documented elsewhere. CLAUDE.md owns the architecture overview, the build commands, and the per-package responsibilities — this file is for the gotchas, the deferred-but-tracked items, the "we already considered and rejected" decisions, and the things that bite if you forget them.
|
||||
|
||||
## Hard SQLite-driver constraints (forget at your peril)
|
||||
|
||||
- `MaxOpenConns(1)` is set on the SQLite connection. **Holding `*sql.Rows` open while calling another function that queries the same `*database.DB` will deadlock.** Close rows explicitly before downstream calls; don't rely on `defer` when the deferred close has to happen *after* a downstream query. This bit smart-playlist S01/T03 and the smart-playlist auto-edit feature; it bites the play-history hook so `OnPlaybackFinished` unlocks the player mutex *before* recording a play.
|
||||
- No CGo: the driver is `modernc.org/sqlite`. Cannot switch to a CGo-based driver — design around it.
|
||||
- Connection pooling, dynamic ORDER BY in sqlc, and `FILTER (WHERE ...)` in older modernc versions all require care. (`FILTER` works in current `modernc.org/sqlite`, but if it ever fails, fall back to `SUM(CASE WHEN ... THEN 1 ELSE 0 END)`.)
|
||||
|
||||
## Explore + Autotagger API-call-minimization playbook
|
||||
|
||||
Every MusicBrainz interaction follows this resolution order — check before sending:
|
||||
|
||||
1. **Local DB** — does a matching `release_groups` row already exist? (zero network cost)
|
||||
2. **Existing partial MBIDs** on the album's tracks — use as Lucene filters (`arid:`, `rgid:`) to narrow search and produce deterministic cache keys.
|
||||
3. **`http_cache`** (already wrapped transparently by `MusicBrainzClient`) — serves hits for any previously-fetched entity.
|
||||
4. **Live MB fetch** — last resort.
|
||||
|
||||
Plus: share fan-out (one `LookupArtist` per album, not per track); persist decisions to `tagging_items` so reopening the review UI fires zero MB calls; prefetch album N+1 while user reviews album N; cover art is pull-on-apply only; auto-accept never fetches incrementally.
|
||||
|
||||
Cache TTLs: 24 h for searches (data updates often), 7 d for entity lookups (artist details, discography are stable).
|
||||
|
||||
## Tag-editing pipeline invariants
|
||||
|
||||
- **`AtomicWrite`** writes to `<filename>.yj-tmp` in the same directory, then renames. Same-directory rename avoids cross-device issues; deterministic suffix enables orphan cleanup on startup.
|
||||
- **Upsert-and-relink for entity sync.** Never mutate shared `artists` / `albums` / `genres` rows in place — create new ones or relink to existing rows. Safe under concurrent reads.
|
||||
- **Currently-playing file gets stopped before its tag write.** `PlayerStopper` interface (implemented by `playerAdapter` in `app.go`) breaks the import cycle.
|
||||
- **Scan and write are mutually exclusive** via `pipelineMu` in the library package.
|
||||
- **Batch writes coalesce events** with a `suppressEvents` flag — one `TrackMetadataChanged` per batch instead of N.
|
||||
|
||||
## Frontend gotchas
|
||||
|
||||
- **Wails TS bindings for smart-playlist methods are manually maintained** in `frontend/wailsjs/go/playlist/Service.{d.ts,js}`. The Wails build does *not* re-generate them in worktrees, and no build-time check catches drift if Go signatures change. Same for any explore method added outside a clean Wails build.
|
||||
- **`pnpm build` runs from `frontend/`, not project root.** Root `package.json` is empty.
|
||||
- **Combobox blur-vs-click race** — the `mousedown` + `preventDefault()` + `requestAnimationFrame` fallback in `combobox.ts` is fragile if option rendering moves into a separate shadow DOM. Re-verify click-to-select after any combobox refactor.
|
||||
- **`go build ./...` fails in git worktrees** because `main.go:28` embeds `frontend/dist`, which doesn't exist in a fresh worktree. Use `go build ./backend/...` for backend-only verification.
|
||||
- Explore detail components duplicate `CoverArtGroupURL`, `nameToHue`, `extractYear`, `formatDuration` from `explore-view`. Three consumers as of v1.3 planning. If a fourth emerges, extract to `explore-utils.ts`.
|
||||
|
||||
## Lint baseline
|
||||
|
||||
`make lint` may report a small number of pre-existing warnings (3 wsl_v5 in `database_test.go`, 1 gci + 1 revive in `smartplaylist.go` last time anyone counted). Don't chase these in unrelated PRs. Note them as pre-existing in verification evidence and move on. Anything new must be clean.
|
||||
|
||||
## Out-of-scope decisions worth remembering the *why* for
|
||||
|
||||
- **Separate databases per library** — defeats unified presentation; rejected.
|
||||
- **Auto-dedup across libraries** — complex matching logic, not table stakes.
|
||||
- **Parallel library scanning** — SQLite single-writer; pointless.
|
||||
- **ORM / query builder** — fights existing sqlc architecture.
|
||||
- **Connection pooling for SQLite** — meaningless under `MaxOpenConns(1)`.
|
||||
- **Database health checking / reconnection** — desktop app context, low priority.
|
||||
- **Cosmetic file splitting** — large files are only a problem if they cause real issues. Extract for reuse or correctness, not aesthetics.
|
||||
- **Parenthesized boolean logic in smart playlists** — UI complexity not worth the use case; AND-only with multi-value `is_any_of` covers the vast majority.
|
||||
- **"Is favorited" as a smart-playlist filter** — favoriting is a special-case relationship, not a queryable field.
|
||||
- **Playing audio remotely from MB** — MB is a metadata catalog, not a streaming service.
|
||||
- **Fuzzy auto-accept threshold slider** — strict all-match is the trustworthy default; a slider is a power-user foot-gun.
|
||||
- **Manual MB search UI in autotag** — Paste-URL covers the escape hatch; full search is surface area we don't need yet.
|
||||
- **Configurable field whitelist for autotag writes** — hardcoded list in v1; add config only if users actually ask.
|
||||
- **Folder-level cover art (`folder.jpg`/`cover.png`)** — separate feature area from embedded art.
|
||||
- **Per-library autotagger on/off** — single global setting; per-library adds UI for no clear benefit.
|
||||
|
||||
## Known gap from milestone 007
|
||||
|
||||
R032 (offline visual indicator for cached Explore data) was scoped into M004 but not implemented. The cache layer works correctly — entries are served when offline until TTL expires — but `Cache.Get()` doesn't propagate a "from cache" flag, and no frontend component renders a "Cached" badge. If picked up: backend modifies `Cache.Get()` to return a `fromCache` bool, frontend adds a subtle badge. ~30 min of work.
|
||||
|
||||
## Open architecture questions
|
||||
|
||||
- **VA compilation detection threshold for the autotagger.** Per-track artist credits differing from album-artist is the easy heuristic. What's the threshold on number of differing tracks before we relax the artist-match rule? Will surface during scoring tuning in plan 009.
|
||||
- **Cover Art Archive minimum-dimension check.** REVIEW-05 (plan 010) sets 500 px on the shortest side. Coarse but cheap. Expect tuning once we see real CAA quality variance across genres.
|
||||
- **Rate-limit priority queue design.** CFG-02 (plan 012) wants user-initiated MB calls to jump ahead of background auto-accept. Implementation strategy is open — priority channel? Two limiters with a yield mechanism? Solve when 012 starts.
|
||||
@@ -1,61 +0,0 @@
|
||||
# YellowJacket — Roadmap
|
||||
|
||||
A cross-platform desktop music player. Plays local MP3 / FLAC / OGG / WAV; manages multiple library directories via SQLite; queue, playlists, smart playlists, play history, full-text search, cover art, MPRIS controls on Linux, full single + batch tag editing across all four formats, and a read-only MusicBrainz / ListenBrainz catalog browser.
|
||||
|
||||
The guiding rule for everything below is "the music player works reliably and feels solid" — every interaction should be correct, responsive, and trustworthy. New surface area only goes in once the foundation under it is stable.
|
||||
|
||||
## Capability set
|
||||
|
||||
- **Playback** — play / pause / seek / volume on MP3, FLAC, OGG, WAV. Queue with shuffle (Fisher-Yates), repeat (off / one / all), auto-advance, persistent across restarts.
|
||||
- **Library** — multiple library directories, concurrent metadata extraction, adaptive scan concurrency by disk type, cancellable / pausable scans, cross-library playlists with phantom-track preservation when a library is removed.
|
||||
- **Search & browse** — FTS5 across tracks/artists/albums/paths; browse by albums / artists / genres; library filter respected everywhere; virtual scrolling.
|
||||
- **Playlists** — CRUD, M3U8 import/export, favorites, smart playlists (rule-based saved queries with combobox editor + live preview), default playlist.
|
||||
- **Tag editing** — single + batch edit of 8 fields across all four formats; cover art embed/replace/remove; crash-safe atomic writes; instant DB + FTS5 sync, no rescan needed.
|
||||
- **Play history** — natural-finish play counting, `last_played` timestamp, play-history log, integration into smart playlist rule fields.
|
||||
- **Explore** — read-only MusicBrainz / ListenBrainz browser: search → artist page → album detail with release-version selection, with rate-limited APIs and SQLite-cached responses.
|
||||
- **Configuration & system** — TOML config with live reload, configurable keyboard shortcuts (record-style capture, scope-aware dispatch), theme tokens (accent / background shade), MPRIS2 on Linux.
|
||||
|
||||
## Milestone sequence
|
||||
|
||||
| # | Milestone | Status | File |
|
||||
|---|-----------|--------|------|
|
||||
| 001 | v1.0 Consolidation | shipped 2026-03-05 | [completed/001-v1.0-consolidation.md](plans/completed/001-v1.0-consolidation.md) |
|
||||
| 002 | v1.1 Multi-Library Support | shipped 2026-03-16 | [completed/002-v1.1-multi-library.md](plans/completed/002-v1.1-multi-library.md) |
|
||||
| 003 | v1.2 Tag Editing (MP3 + FLAC) | shipped 2026-03-18 | [completed/003-v1.2-tag-editing.md](plans/completed/003-v1.2-tag-editing.md) |
|
||||
| 004 | v1.2.1 Format Parity (OGG + WAV) | shipped 2026-03-21 | [completed/004-v1.2.1-format-parity.md](plans/completed/004-v1.2.1-format-parity.md) |
|
||||
| 005 | Smart Playlists | shipped 2026-03-22 | [completed/005-smart-playlists.md](plans/completed/005-smart-playlists.md) |
|
||||
| 006 | Play History & Play Count | shipped 2026-03-22 | [completed/006-play-history.md](plans/completed/006-play-history.md) |
|
||||
| 007 | MusicBrainz/ListenBrainz Explore Browser | shipped 2026-03-24 | [completed/007-explore-browser.md](plans/completed/007-explore-browser.md) |
|
||||
| 008 | Autotag — Schema & Grouping Foundation | shipped 2026-04-20 | [completed/008-autotag-schema-grouping.md](plans/completed/008-autotag-schema-grouping.md) |
|
||||
| 009 | Autotag — Scoring Engine & MB Orchestration | shipped 2026-04-21 | [completed/009-autotag-scoring-engine.md](plans/completed/009-autotag-scoring-engine.md) |
|
||||
| 010 | Autotag — Review UI & Apply Pipeline | **active** | [active/010-autotag-review-ui.md](plans/active/010-autotag-review-ui.md) |
|
||||
| 011 | Autotag — Auto-Accept & Entry Points | pending | [pending/011-autotag-auto-accept.md](plans/pending/011-autotag-auto-accept.md) |
|
||||
| 012 | Autotag — Settings & Polish | pending | [pending/012-autotag-settings-polish.md](plans/pending/012-autotag-settings-polish.md) |
|
||||
|
||||
The active phase is the **MusicBrainz autotagger** (collectively v1.3). It builds on the explore-browser API client + cache foundation from milestone 007. Plans 008-012 are sequential — each depends on the prior one. See `NOTES.md` for the API-call-minimization playbook and the design principles that constrain every Autotag plan.
|
||||
|
||||
## Beyond v1.3 (not yet planned)
|
||||
|
||||
Captured here so they don't get lost; not yet promoted to plan files.
|
||||
|
||||
- **ListenBrainz scrobbling.** Submit play data when a track crosses the scrobble threshold (`min(duration / 2, 4 minutes)`). Three submission types — `playing_now`, `single`, `import`. Hook slots in next to the existing play-history pipeline; MBIDs already flow through the stack thanks to v1.3.
|
||||
- **Gapless playback + crossfade.** Seamless transitions with optional crossfade.
|
||||
- **Layout customization system.** Section-based UI customization; components declare size constraints, users configure per-section.
|
||||
- **Plugin system.** Full-access API for UI components and backend hooks.
|
||||
- **AcoustID fingerprinting** via fpcalc — slots in behind the v1.3 `Identifier` interface seam.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Things deliberately **not** going in. See `NOTES.md` for the reasoning behind each.
|
||||
|
||||
- Separate databases per library, auto-dedup across libraries, user access control per library.
|
||||
- Parallel library scanning (SQLite single-writer).
|
||||
- Cross-platform media controls beyond MPRIS on Linux.
|
||||
- Database health checking / reconnection.
|
||||
- ORM or query builder, connection pooling for SQLite.
|
||||
- Parenthesized boolean logic in smart playlists; "is favorited" as a filter; queue that re-evaluates rules during playback.
|
||||
- Playing audio from MusicBrainz remotely (it's a metadata catalog, not a streaming service).
|
||||
- Integrating any service beyond MusicBrainz / ListenBrainz / Cover Art Archive without explicit user approval.
|
||||
- Fuzzy auto-accept threshold sliders (strict all-match is the trustworthy default).
|
||||
- Manual MB search UI (Paste-URL covers the escape-hatch case).
|
||||
- Cover art replacement during auto-accept (highest-regret op — never automatic in v1).
|
||||
@@ -0,0 +1,162 @@
|
||||
# Autotag (v1.3) — MusicBrainz Autotagger
|
||||
|
||||
The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008–012), each depending on the prior one.
|
||||
|
||||
| Phase | Title | Status |
|
||||
|-------|-------|--------|
|
||||
| 008 | Schema & Grouping Foundation | shipped 2026-04-20 |
|
||||
| 009 | Scoring Engine & MB Orchestration | shipped 2026-04-21 |
|
||||
| 010 | Review UI & Apply Pipeline | **active** |
|
||||
| 011 | Auto-Accept & Entry Points | pending |
|
||||
| 012 | Settings & Polish | pending |
|
||||
|
||||
---
|
||||
|
||||
## 008 — Schema & Grouping Foundation · shipped 2026-04-20
|
||||
|
||||
> First v1.3 phase. Lays down the schema + bookkeeping — no scoring, no UI, no MB calls yet.
|
||||
|
||||
### What landed
|
||||
|
||||
- **008.1 — Migration 31: `audio_files.tag_status`.** Text column with inline `CHECK(tag_status IN (...))` constraint (SQLite `ALTER TABLE ADD COLUMN` supports column constraints, so both fresh and upgraded DBs enforce it). Partial index `idx_audio_files_tag_status_untagged ON audio_files(library_id) WHERE tag_status = 'untagged'` powers the pending badge. Backfill sets `user_confirmed` where the joined recording already has an MBID; everything else defaults to `untagged`.
|
||||
- **008.2 — Migration 32: `tagging_items` + `group_key`.** New `tagging_items` table (PK `group_key`, `status CHECK IN ('pending','matched','confirmed','skipped')`, two indexes including the partial `WHERE status = 'pending'` for the badge). `audio_files.group_key TEXT NOT NULL DEFAULT ''` with partial index `WHERE group_key != ''`. Column-referencing indexes live in the migration, not the schema file, because `CREATE TABLE IF NOT EXISTS` is a no-op on existing tables and the partial-index predicate would reference a column that hasn't been added yet.
|
||||
- **008.3 — `backend/autotag.GroupKey` + scan-path integration.** Lower-case hex SHA-1 over `libraryID || 0 || parentDirLower || 0 || albumTrimmed || 0 || discNumber` — intentionally shallow, deeper normalization is scoring territory (009). `saveAudioFile` switched to `CreateAudioFileWithGroupKey` + `UpsertTaggingItemOnTrackAdd`. `updateAudioFileMetadata` rebinds on key change: decrement old group → delete if empty → upsert new group → write new key onto the audio_files row. Migration 32 streams existing rows in batches of 500 and aggregates `tagging_items` at the end, defaulting status to `confirmed` when every track in the group is `user_confirmed`.
|
||||
- **008.4 — Pending-queue sqlc queries.** `CountPendingTaggingItems`, three `ListPendingTaggingItems...` variants (alphabetical, by-score nulls-last, by-recent) — sqlc has no dynamic ORDER BY so each sort has its own query. `CAST(@param AS INTEGER|TEXT)` hints give typed params (otherwise sqlc emits `interface{}`). `GetTaggingItem` and `ListAudioFilesInTaggingGroup` round out the queue API. `EXPLAIN QUERY PLAN` test asserts the badge query uses `idx_tagging_items_status_pending` so a future schema change that breaks the partial index fails loudly.
|
||||
|
||||
### Key decisions retained
|
||||
|
||||
- **Hash algorithm in Go, not SQL.** `autotag.GroupKey` is the single source of truth for the key format so we can evolve it without coupling to SQLite functions.
|
||||
- **SHA-1 over alternatives.** Matches the codebase's existing non-crypto deterministic-key convention. Collision risk at album-group cardinality (millions) is irrelevant.
|
||||
- **Null-byte separators between hash inputs.** Prevents `a|b` vs `ab|` ambiguity.
|
||||
- **Per-track integration inside `commitBatch`'s transaction**, not a post-scan callback — keeps `tagging_items` coherent after partial scans.
|
||||
- **`best_match_release_mbid`, `score`, `last_checked_at` shapes fixed now** even though they stay NULL until 009-010. Avoids schema churn later.
|
||||
|
||||
---
|
||||
|
||||
## 009 — Scoring Engine & MB Orchestration · shipped 2026-04-21
|
||||
|
||||
> Second v1.3 phase. Given an album-group, produce a ranked list of candidate releases with per-track alignment data, using as few MusicBrainz calls as the API-minimization playbook allows. No UI yet — 010 surfaces this to the user.
|
||||
|
||||
### What landed
|
||||
|
||||
- **`backend/autotag/` domain types** — `Candidate`, `TrackAlignment`, `GroupScore`, `LocalTrack`, `CandidateSource` (`local` / `musicbrainz`), `AlignmentStatus` (`matched` / `missing` / `extra` / `mismatched`).
|
||||
- **`Normalize(s)`** — Unicode NFC → qualifier-suffix strip (`(Remastered 2009)`, `[Bonus Track]`, `(feat. X)`, etc.) → case fold → punctuation drop → whitespace collapse. Comparison-only, not human-readable.
|
||||
- **Per-track distance** — weighted 60% title similarity (1.0 − Levenshtein/max-len), 30% length delta (linear: ≤1 s = 1.0, ≥30 s = 0.0, neutral 0.5 when either side unknown), 10% track-number match.
|
||||
- **Greedy alignment** — `AlignTracks` picks the highest-scoring (local, cand) pair iteratively; not Hungarian-optimal but fine at album cardinalities. Emits `matched` / `missing` / `extra` / `mismatched` rows so the review UI can render the diff.
|
||||
- **Local-first resolver** — `ListLocalReleaseGroupCandidates` sqlc query pre-filters on `rg.mbid != '' AND r.mbid != '' AND rg.name = ? COLLATE NOCASE`, then Go applies full normalization. Candidate track lists only include recordings that themselves have MBIDs (avoids untagged dupes polluting the "canonical" view when the same `(name, artist)` release group is shared across libraries).
|
||||
- **MB orchestration** — `MBClient` interface (`SearchReleaseGroups`, `BrowseReleases`, `LookupArtist`) hides `explore.MusicBrainzClient`; `backend/explore/autotagclient.go` adapts one to the other. `buildMBQuery` assembles `release:"X" AND arid:<mbid> AND tracks:N` — `arid:` makes the search cache key deterministic, `tracks:N` filters out box-set-style releases. One search per album + one `BrowseReleases` per candidate RG.
|
||||
- **Release-level ranker** — aggregate track score (70%) + track-count match (15%, zero at ≥50% delta) + meta (15%, avg of year/Official/country bonuses). `Scorer.ScoreGroup` hits local first, runs MB only when no local candidate scores ≥ 0.90.
|
||||
- **Persistence** — `SetTaggingItemBestMatch` writes `best_match_release_mbid`, `score`, `last_checked_at = CURRENT_TIMESTAMP`, `status = 'matched'`.
|
||||
|
||||
### Key decisions retained
|
||||
|
||||
- **SHA-1-grade normalization vs. full MB-equivalent.** Qualifier regex handles the common cases (remaster, deluxe, explicit, feat., bonus, etc.) without dragging in a full MB title-parsing library. Edges that bite in real libraries will show up in 010 review UX and can be patched then.
|
||||
- **`*sqlcgen.Queries` as the DB boundary for autotag**, not `*database.DB`. The `database` package already depends on `autotag.GroupKey` (from 008.3), so reversing the dependency via `database` would create a cycle. Using `sqlcgen` directly is acyclic and keeps `autotag` swappable.
|
||||
- **Scorer constructor takes `MBClient` as interface, not `*explore.MusicBrainzClient`.** Lets tests inject a stub without spinning up the HTTP + cache layer. The concrete adapter lives in `explore/autotagclient.go`.
|
||||
- **`localSufficient = 0.90` threshold for skipping MB.** Empirical guess — will get retuned in 011 auto-accept phase when we observe real corpus scores.
|
||||
- **Weights `60/30/10` for title/length/track-number.** Cribbed from beets' broad intuition; tuned to emphasize title-matching since length data can be unreliable from Vorbis Comments. Tests document the expected floors (e.g. "exact match should score ≥ 0.99") so nudging weights won't silently regress.
|
||||
- **`release_groups` and `recordings` must both carry MBIDs** for a local candidate. Otherwise untagged dupes of the same album (across libraries) falsely expand the "canonical" track list.
|
||||
- **UTF-8 em-dashes in SQL comments broke sqlc's string-literal emitter**, truncating generated query strings mid-word. All autotag SQL comments use ASCII punctuation.
|
||||
|
||||
### Known follow-ups into 010+
|
||||
|
||||
- **`guessArtistMBID` is a stub** returning `""` because `LocalTrack` doesn't currently carry artist MBIDs. 010 should thread artist MBIDs through `ListAudioFilesInTaggingGroup` so the MB resolver can use `arid:` filters.
|
||||
- **`yearBonus` uses `time.Now().Year()`** as a placeholder target. Should become the earliest release-date hint from the group's tracks once 010 provides it.
|
||||
- **VA compilation detection threshold** is still open. The scorer doesn't special-case per-track artist credits differing from album-artist.
|
||||
|
||||
---
|
||||
|
||||
## 010 — Review UI & Apply Pipeline · ACTIVE
|
||||
|
||||
> Third v1.3 phase. User reviews one album at a time, sees the diff clearly, and applies or skips — file tags get written, DB gets synced, cover art follows the never-replace-existing rule.
|
||||
|
||||
**Requirements:** REVIEW-01..07 · **Depends on:** 009 (needs candidates + scores)
|
||||
|
||||
### Success criteria
|
||||
|
||||
1. `/autotag` shows the next pending album with its top candidate as a field-by-field diff. Missing-from-local and extra-in-local tracks are shown explicitly.
|
||||
2. Keyboard shortcuts work without the mouse: `A` apply, `M` more candidates, `S` skip, `L` leave-as-is, `U` paste URL, `→`/`←` navigate.
|
||||
3. Apply writes tags to every track in the group via the existing format-specific writers + atomic write + DB sync + FTS5 sync. Whitelisted fields only: title, artist, album, album-artist, year, track#, track-total, disc#, disc-total, all MBIDs.
|
||||
4. Cover art rule: embed only when the file has no existing art **and** CAA returns ≥500 px on the shortest side. Never replace existing embedded art (auto or manual).
|
||||
5. First-ever apply per library shows an irreversibility warning. "Don't show again" sets a flag on the `libraries` row; never shows again for that library.
|
||||
6. While the user reviews album N, candidates for album N+1 are prefetched into `http_cache` so advancing feels instant.
|
||||
7. "Paste MB URL" dialog accepts a release URL, extracts the MBID, runs one `LookupRelease`, renders the diff against the current album.
|
||||
|
||||
### Sub-plans
|
||||
|
||||
- Wails bindings — `StartAutotagQueue`, `GetCurrentCandidate`, `GetCandidates(groupKey)`, `Apply(groupKey, releaseMBID)`, `Skip`, `LeaveAsIs(groupKey)`, `RetagGroup(groupKey)`.
|
||||
- `/autotag` page layout — focused album header, diff table, candidate sidebar, missing/extra panel.
|
||||
- Keyboard shortcut wiring through the existing scope-aware dispatch.
|
||||
- Apply pipeline integration with existing tag writers + DB sync.
|
||||
- Cover art apply rule + CAA fetch + 500 px minimum check.
|
||||
- File-write warning dialog with per-library persistence.
|
||||
- Prefetch-next-album goroutine, rate-limiter aware.
|
||||
- Paste-MB-URL escape hatch.
|
||||
|
||||
### Risk callout
|
||||
|
||||
Every apply rewrites a file. The `AtomicWrite` pipeline mitigates corruption risk; the per-library warning mitigates surprise. Dry-run mode (from 009) lets developers validate scoring changes without file writes.
|
||||
|
||||
---
|
||||
|
||||
## 011 — Auto-Accept & Entry Points · pending
|
||||
|
||||
> Fourth v1.3 phase. The strict all-match auto-accept path runs as a background job; the tool is reachable from every place a user expects.
|
||||
|
||||
**Requirements:** AUTO-01..06 · **Depends on:** 010 (needs the apply pipeline)
|
||||
|
||||
### Success criteria
|
||||
|
||||
1. An album-group qualifies for auto-accept iff: exact track-count match, every track's normalized title matches, every track's length within ±2s, no cover-art replacement required, no existing-MBID conflicts. Decision uses already-cached candidate data — **no additional MB calls**.
|
||||
2. Auto-accept job processes all qualifying groups in the queue, emits progress events, is cancellable at any point, honors the shared rate limiter.
|
||||
3. Right-click on a track / album / artist exposes "Autotag this album" (queues + jumps to review) and "Retag" (flips status to `untagged` and requeues).
|
||||
4. After a library scan finishes with N new untagged albums, a non-blocking toast appears linking to `/autotag`.
|
||||
5. Sidebar has an "Autotag" nav entry with a pending-count badge, updates reactively.
|
||||
6. Pasting a MB release URL into the Paste-URL dialog renders a full diff against the current album with one `LookupRelease` call.
|
||||
|
||||
### Sub-plans
|
||||
|
||||
- Strict all-match rule + unit tests.
|
||||
- Auto-accept background job — progress events, cancellation, queue traversal.
|
||||
- Context menu integrations on track/album/artist views.
|
||||
- Post-scan toast wiring via the existing scan-complete event.
|
||||
- Sidebar nav entry + pending-count badge store integration.
|
||||
|
||||
### Risk callout
|
||||
|
||||
Release selection can pick the wrong edition. The exact-track-count gate prevents most silent misbehavior, but bonus-track editions and remaster reissues with matching track counts are genuine ambiguity. Manual review handles the edge cases — that's why auto-accept is strict by design, and the slider for fuzzy auto-accept is explicitly out of scope.
|
||||
|
||||
---
|
||||
|
||||
## 012 — Settings & Polish · pending
|
||||
|
||||
> Fifth and final v1.3 phase. Configuration surfaces in the existing settings system; the known sharp edges (rate-limit contention, VA compilations, singleton files) get sanded; the fingerprinting seam is in place for the future.
|
||||
|
||||
**Requirements:** CFG-01..06 · **Depends on:** 011
|
||||
|
||||
### Success criteria
|
||||
|
||||
1. Autotag settings panel accessible via the existing templ/HTMX settings UI. Exposes: enable/disable auto-accept, per-library file-write warning reset, default review filter, default sort order.
|
||||
2. Shared rate limiter distinguishes interactive from background requests. User-initiated MB calls (paste-URL, opening a review, explore browsing) are never blocked behind a running auto-accept job.
|
||||
3. VA compilation albums (per-track artist credits differ from album-artist) are detected. The auto-accept artist-match rule relaxes for them; the ranker prefers MB releases credited to "Various Artists".
|
||||
4. Singleton files (`track_count = 1`, no sibling context) use a recording-level match path (`SearchRecordings` with title + artist + length filters). Lower confidence ceiling — never eligible for auto-accept regardless of confidence.
|
||||
5. `type Identifier interface { Identify(path) ([]Candidate, error) }` exists with `MetadataIdentifier` as the v1 implementation. No fpcalc integration, but the seam is in place for a future `AcoustIDIdentifier`.
|
||||
6. User-facing quickstart docs exist; CLAUDE.md gets a `backend/autotag/` package description; scoring-function dev notes are committed.
|
||||
|
||||
### Sub-plans
|
||||
|
||||
- Autotag settings panel (templ + HTMX).
|
||||
- Rate-limiter priority support.
|
||||
- VA compilation detection and scoring adjustments.
|
||||
- Singleton-file match path.
|
||||
- `Identifier` interface seam with `MetadataIdentifier`.
|
||||
- Docs — user quickstart + dev notes + CLAUDE.md update.
|
||||
|
||||
---
|
||||
|
||||
## Ship criteria for v1.3 overall
|
||||
|
||||
- All 29 SCHEMA/MATCH/REVIEW/AUTO/CFG requirements complete.
|
||||
- All five phases' success criteria verified end-to-end on a real library (10k+ tracks, mixed match quality).
|
||||
- Auto-accept run against a well-tagged subset produces zero incorrect matches.
|
||||
- Manual review workflow can process 100 albums in under 30 minutes without mouse use.
|
||||
@@ -1,30 +0,0 @@
|
||||
# 010 — Autotag: Review UI & Apply Pipeline
|
||||
|
||||
> Third v1.3 phase. User reviews one album at a time, sees the diff clearly, and applies or skips — file tags get written, DB gets synced, cover art follows the never-replace-existing rule.
|
||||
|
||||
**Status:** pending · **Requirements:** REVIEW-01..07 · **Depends on:** 009 (needs candidates + scores)
|
||||
|
||||
## Success criteria
|
||||
|
||||
1. `/autotag` shows the next pending album with its top candidate as a field-by-field diff. Missing-from-local and extra-in-local tracks are shown explicitly.
|
||||
2. Keyboard shortcuts work without the mouse: `A` apply, `M` more candidates, `S` skip, `L` leave-as-is, `U` paste URL, `→`/`←` navigate.
|
||||
3. Apply writes tags to every track in the group via the existing format-specific writers + atomic write + DB sync + FTS5 sync. Whitelisted fields only: title, artist, album, album-artist, year, track#, track-total, disc#, disc-total, all MBIDs.
|
||||
4. Cover art rule: embed only when the file has no existing art **and** CAA returns ≥500 px on the shortest side. Never replace existing embedded art (auto or manual).
|
||||
5. First-ever apply per library shows an irreversibility warning. "Don't show again" sets a flag on the `libraries` row; never shows again for that library.
|
||||
6. While the user reviews album N, candidates for album N+1 are prefetched into `http_cache` so advancing feels instant.
|
||||
7. "Paste MB URL" dialog accepts a release URL, extracts the MBID, runs one `LookupRelease`, renders the diff against the current album.
|
||||
|
||||
## Sub-plans
|
||||
|
||||
- Wails bindings — `StartAutotagQueue`, `GetCurrentCandidate`, `GetCandidates(groupKey)`, `Apply(groupKey, releaseMBID)`, `Skip`, `LeaveAsIs(groupKey)`, `RetagGroup(groupKey)`.
|
||||
- `/autotag` page layout — focused album header, diff table, candidate sidebar, missing/extra panel.
|
||||
- Keyboard shortcut wiring through the existing scope-aware dispatch.
|
||||
- Apply pipeline integration with existing tag writers + DB sync.
|
||||
- Cover art apply rule + CAA fetch + 500 px minimum check.
|
||||
- File-write warning dialog with per-library persistence.
|
||||
- Prefetch-next-album goroutine, rate-limiter aware.
|
||||
- Paste-MB-URL escape hatch.
|
||||
|
||||
## Risk callout
|
||||
|
||||
Every apply rewrites a file. The `AtomicWrite` pipeline mitigates corruption risk; the per-library warning mitigates surprise. Dry-run mode (from 009) lets developers validate scoring changes without file writes.
|
||||
@@ -1,22 +0,0 @@
|
||||
# 001 — v1.0 Consolidation
|
||||
|
||||
> Foundation milestone: fixed all SetContext data races, established the test infrastructure, consolidated SQL, and tuned backend + frontend performance so feature work could safely follow.
|
||||
|
||||
**Shipped:** 2026-03-05 · **Phases:** 1-8
|
||||
|
||||
## What landed
|
||||
|
||||
- **Phase 1 — Concurrency race fixes.** Mutex-protected `SetContext` across queue, library, playlist, player; collapsed Player double-lock. App runs `-race`-clean.
|
||||
- **Phase 2 — Backend correctness.** `startupErr` moved to struct field; config files written 0o644; MPRIS callback errors logged; `Library.Scan()` separates warnings (`ScanMetrics`) from fatal errors (return value).
|
||||
- **Phase 3 — Test infrastructure.** `database.NewTestDB(t)` returns an in-memory SQLite with full migrations + production PRAGMAs (`synchronous=NORMAL`, `cache_size=-8000`, `mmap_size=67108864`).
|
||||
- **Phase 4 — Queue/Config/Player tests.** ~30 tests covering SetQueue, navigation, shuffle, repeat modes, persistence, config load/save roundtrip, volume conversion, format detection.
|
||||
- **Phase 5 — Database/Library tests.** ~25 tests covering FTS5 search, search index rebuild, migrations, scan logic, entity cache.
|
||||
- **Phase 6 — SQL consolidation.** 5-table FTS5 JOIN consolidated into the `track_metadata` VIEW; AST-based event codegen (Go → TypeScript) with pre-commit hook; `sqlc.slice()` for queue batch lookups; every hand-crafted SQL statement carries a `// SAFETY:` comment explaining why.
|
||||
- **Phase 7 — Backend performance.** Incremental queue persistence (O(1) add/remove), SetQueue Phase-2 dedup, library store deferred loading.
|
||||
- **Phase 8 — Frontend performance.** Lit `repeat()` with stable keys; `queueMicrotask` notification batching; design token system; visual consistency audit across 15 components.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **No cosmetic file splitting** — extract only when reuse or correctness demands it.
|
||||
- **Tests as a means to safe refactoring**, not a coverage target.
|
||||
- **Fix races → tests → refactoring** order is mandatory: can't run `-race`-clean tests with active races; can't safely refactor without tests.
|
||||
@@ -1,23 +0,0 @@
|
||||
# 002 — v1.1 Multi-Library Support
|
||||
|
||||
> Lets users register and manage multiple music library directories with per-library scanning, filtered views, cross-library playlists, and phantom-track preservation when a library is removed.
|
||||
|
||||
**Shipped:** 2026-03-16 · **Phases:** 9-14
|
||||
|
||||
## What landed
|
||||
|
||||
- **Phase 9 — Scan cancellation & keyboard shortcuts.** Cancellable/pausable library scans via per-scan context; configurable shortcuts with record-style capture, scope-aware dispatch, conflict detection.
|
||||
- **Phase 10 — Schema & migration.** New `libraries` table, `library_id` FK on `audio_files`, phantom columns on `playlist_tracks`, seamless single-directory → named-library migration on first launch.
|
||||
- **Phase 11 — Per-library scan pipeline.** Sequential scan-queue coordinator (single writer is the SQLite reality); per-library progress UI with library name; cancel scope respects the active library.
|
||||
- **Phase 12 — Library CRUD & data integrity.** Add/rename/remove library through UI; atomic orphan cleanup (shared artists/albums/genres survive); FTS5 entries removed; queue tracks from a removed library cascade-deleted with playback continuing.
|
||||
- **Phase 13 — Library views & phantom tracks.** Library filter dropdown across all views; tracks/albums/artists/genres/search respect the active filter; cross-library playlists work naturally; removed-library tracks become greyed-out phantoms with preserved metadata, auto-resolved on rescan via `ScanHooks` + M3U8 matching.
|
||||
- **Phase 14 — Performance optimization.** CSS containment + GPU promotion on scroll containers; view caching (display toggle, no DOM destruction); event delegation on virtualizer; `queueMicrotask` notification batching; pprof profiling guide.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **Hybrid model — `library_id` only on `audio_files`.** Physical files belong to a library; logical entities (artist/album/genre) are shared.
|
||||
- **Libraries live in DB, not TOML.** UI-driven CRUD shouldn't require TOML manipulation.
|
||||
- **`SET NULL` on `playlist_tracks` FK.** Phantom rows preserve playlist structure when a library is removed.
|
||||
- **Backend filters, frontend doesn't.** `ByLibrary` SQL variants keep the UI responsive at 150k tracks.
|
||||
- **Sequential scanning.** SQLite single-writer makes parallel scans pointless.
|
||||
- **`ScanHooks` callback pattern.** Breaks the library → playlist circular dep needed for phantom resolution.
|
||||
@@ -1,24 +0,0 @@
|
||||
# 003 — v1.2 Tag Editing (MP3 + FLAC)
|
||||
|
||||
> First write path into user files: edit any track's metadata or cover art (single or batch) with crash-safe writes, then synchronize the database, FTS5 index, and entity graph in the same operation. MP3 and FLAC only — OGG and WAV ship in v1.2.1.
|
||||
|
||||
**Shipped:** 2026-03-18 · **Phases:** 15-18
|
||||
|
||||
## What landed
|
||||
|
||||
- **Phase 15 — Schema migration & write safety.** FTS5 migrated to `contentless_delete=1` so per-row deletes/updates work without ghost search hits. New `backend/fileutil` package provides `AtomicWrite` (write to `.yj-tmp` sibling, then rename) with crash-safe semantics and orphan cleanup on startup.
|
||||
- **Phase 16 — Tag writers + DB sync.**
|
||||
- `backend/tagwriter/` writes ID3v2 (MP3) and Vorbis Comments (FLAC), including embedded cover art.
|
||||
- `WriteTrackTags` pipeline: file write → transactional DB sync (entity upsert-and-relink + FTS5 update + orphan cleanup) → event emission.
|
||||
- `PlayerStopper` interface (implemented by `playerAdapter` in `app.go`) lets tagwriter request playback stop without an import cycle.
|
||||
- Scan/write mutual exclusion via `pipelineMu`.
|
||||
- **Phase 17 — Single-track editor.** Right-click any track anywhere; 8 editable fields (title/artist/album/genre/year/track#/disc#/composer); cover art pick/replace/remove; diff-only saves; all visible views refresh without rescan.
|
||||
- **Phase 18 — Batch editor.** Multi-select; three-state field model (keep / set / clear) driven implicitly by `editValues` presence; confirmation guard; progress bar with cancellation; partial-failure reporting; batch cover art set/clear.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **Upsert-and-relink for entity sync.** Never mutate shared entities — create new rows or relink to existing ones. Safe under concurrent reads.
|
||||
- **`AtomicWrite` with deterministic `.yj-tmp` suffix.** Same-directory rename avoids cross-device issues; deterministic naming enables orphan cleanup.
|
||||
- **`suppressEvents` flag for batch coalescing.** One `TrackMetadataChanged` event per batch, not N individual ones.
|
||||
- **Three-state field model via dirty tracking.** `editValues` presence is the signal — no explicit state enum needed.
|
||||
- **go-flac ecosystem.** Small library (44★) but the only pure-Go FLAC writer. dhowden/tag reads back what go-flac writes; 7 round-trip tests validate.
|
||||
@@ -1,18 +0,0 @@
|
||||
# 004 — v1.2.1 Format Parity (OGG + WAV)
|
||||
|
||||
> Closes the tag-writing format gap: every file YellowJacket plays can also be edited. OGG Vorbis and WAV writers, plus a wsl_v5 lint cleanup. (This was tracked as M001 in the GSD system; S01–S18 in that milestone were just retroactive imports of v1.0–v1.2.)
|
||||
|
||||
**Shipped:** 2026-03-21 · **Phases:** 19-21
|
||||
|
||||
## What landed
|
||||
|
||||
- **Phase 19 — WAV tag writer.** Custom RIFF parser with lenient-read / strict-write behavior. ID3v2 chunk placed at end of file after preserved chunks. Case-insensitive `id3`/`ID3` chunk detection. Existing ID3v2 tags merged so unknown frames written by other tools survive. Read-back uses `bogem/id3v2.ParseReader` (handles cleared tags correctly; dhowden/tag does not).
|
||||
- **Phase 20 — OGG Vorbis tag writer.** Custom OGG page parser/writer with MSB-first CRC32 (Go's `hash/crc32` uses incompatible reflected bit ordering, so a precomputed 256-entry lookup table lives in-package). Vorbis Comment serializer with `METADATA_BLOCK_PICTURE` cover art. Raw byte preservation for non-edited entries — avoids corrupting non-UTF-8 tags. Tests use a programmatic fixture builder (no embedded binaries). CRC32 validated against independently computed known vectors (`OggS` → `0x5fb0a94f`, `{1..8}` → `0x7d0f3681`).
|
||||
- **Phase 21 — Cleanup.** wsl → wsl_v5 upgrade, all 19 pre-existing warnings fixed.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- 7 FLAC round-trip tests (pre-existing).
|
||||
- WAV round-trip tests covering all 8 text fields + cover art.
|
||||
- OGG round-trip tests with CRC32 validation against known vectors.
|
||||
- All pass with `-race`.
|
||||
@@ -1,25 +0,0 @@
|
||||
# 005 — Smart Playlists
|
||||
|
||||
> Rule-based dynamic playlists: saved queries that evaluate against the library on demand, with a typeable combobox rule editor, live preview, and queue-snapshot-on-play semantics. AND-only logic with multi-value "is any of" — no nested boolean groups.
|
||||
|
||||
**Shipped:** 2026-03-22 · **Slices:** S01-S04 (M002 in GSD)
|
||||
|
||||
## What landed
|
||||
|
||||
- **S01 — Schema & rule engine.** Migration 9 adds `is_smart` + `smart_rules` (JSON) columns to the existing `playlists` table — extends, doesn't duplicate. `backend/smartplaylist/` builds parameterized WHERE clauses via a hardcoded `fieldMap` whitelist (16 columns from `track_metadata`). 7 text operators (`is`, `is_not`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_any_of`) and 5 numeric (`is`, `is_not`, `greater_than`, `less_than`, `between`). 49 unit tests cover all operators, the genre dual-path, and SQL-injection rejection. Optional result limit + sort (whitelisted fields, `ORDER BY RANDOM()` allowed).
|
||||
- **S02 — CRUD & sidebar.** sqlc regenerated for the new columns; `IsSmart bool` propagated through `playlist.Summary` to TypeScript models. Smart playlists render with a filter icon and "Smart" badge. New `smart-playlist-details` Lit element follows the `genre-details` pattern; refresh/play/shuffle wired. Drag-drop onto smart playlists blocked.
|
||||
- **S03 — Rule editor UI.** Reusable `yj-combobox` LitElement (typeable input, ARIA, keyboard nav). `smart-playlist-editor` builds rules row-by-row with field combobox, operator switch (text/numeric auto), value input. Live preview at 300 ms debounce calling `PreviewSmartPlaylist`.
|
||||
- **S04 — Integration.** Create → navigate → `auto-edit` flow — detail view awaits initial data load (avoids `MaxOpenConns(1)` deadlock) before opening the editor.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **One playlists table** — `is_smart` flag, not a parallel table. One code path for listing.
|
||||
- **Saved queries, not saved track lists** — no `playlist_tracks` rows for smart playlists; results evaluated on demand.
|
||||
- **Snapshot to queue on play.** Queue must be stable during playback, not dynamically re-evaluating.
|
||||
- **Genre dual-path.** Exact ops (`is`, `is_not`, `is_any_of`) use a subquery against `recording_genres JOIN genres` because the `GROUP_CONCAT(g.name, '||')` column in `track_metadata` can't match individual names. LIKE-based ops use the concatenated column.
|
||||
- **Wails TS bindings for the 5 smart-playlist methods are manually maintained** (`Service.d.ts` / `Service.js`). No build-time check catches drift if Go signatures change.
|
||||
|
||||
## Fragility notes
|
||||
|
||||
- **Combobox blur-vs-click race.** The `mousedown` + `preventDefault()` + `requestAnimationFrame` fallback in `combobox.ts` is battle-tested but brittle if anyone restructures option rendering into a separate shadow DOM. Re-verify click-to-select in any combobox refactor.
|
||||
- **`MaxOpenConns(1)` deadlock.** Holding `*sql.Rows` open while calling functions that query the same `*database.DB` will deadlock. Close rows explicitly before downstream calls; never rely on `defer` here.
|
||||
@@ -1,19 +0,0 @@
|
||||
# 006 — Play History & Play Count
|
||||
|
||||
> Tracks every natural track completion. Powers smart-playlist rules like "most played", "never played", "not played in 30 days", and exposes play count as an optional track-list column.
|
||||
|
||||
**Shipped:** 2026-03-22 · **Slices:** S01-S03 (M003 in GSD)
|
||||
|
||||
## What landed
|
||||
|
||||
- **S01 — Schema, migration, recording.** Migration 10 adds `play_history` table and denormalized `play_count` + `last_played` columns on `audio_files`; `track_metadata` VIEW recreated to include them. `recordPlay()` pipeline: insert history row → increment count → update timestamp → emit event. `OnPlaybackFinished` hook unlocks the player mutex *before* the DB write (avoids SQLite deadlock).
|
||||
- **S02 — Smart playlist integration.** `play_count` (numeric) and `days_since_played` (computed) added to the rule-engine field whitelist. Explicit NULL handling for never-played tracks in `days_since_played` conditions.
|
||||
- **S03 — UI.** Optional play-count column in track lists; `PlayCount` / `LastPlayed` added to TypeScript models; editor dropdown entries for the new fields.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **Natural finish only** — no percentage threshold, no skip counting. (If you scrub past the end, that doesn't count.)
|
||||
- **Denormalized `play_count`/`last_played` on `audio_files`** — the alternative was per-query aggregation against `play_history`; the cost wasn't worth it.
|
||||
- **`days_since_played` is a computed field**, not raw timestamp comparison in the rule. Lets the rule engine speak the user's vocabulary.
|
||||
- **"Recently played" is a smart playlist**, not a dedicated view. Reuses the existing rule engine instead of adding a parallel query path.
|
||||
- **Mutex unlock before `recordPlay()`** is mandatory — locking around the DB write under `MaxOpenConns(1)` deadlocks.
|
||||
@@ -1,24 +0,0 @@
|
||||
# 007 — MusicBrainz/ListenBrainz Explore Browser
|
||||
|
||||
> Read-only remote catalog browser: search MB/LB, browse artist pages with ListenBrainz-ranked top tracks and similar artists, view album detail with release-version selection. Rate-limited (1 req/sec), SQLite-cached, with cover art from CAA. Lays the API client + cache foundation that v1.3 Autotagger builds on.
|
||||
|
||||
**Shipped:** 2026-03-24 · **Slices:** S01-S04 (M004 in GSD)
|
||||
|
||||
## What landed
|
||||
|
||||
- **S01 — API clients, cache, explore shell.** `backend/explore/` — MusicBrainz client (wraps `musicbrainzws2`, which provides its own internal rate limiter), ListenBrainz client (custom `RateLimiter` via `golang.org/x/time/rate`, 1 req/sec, burst=1), Cover Art Archive client (URL builders only). All clients follow cache-first pattern: check cache → call API → wrap → cache → return. Migration 11 adds `explore_cache` table (URL key, JSON body, datetime expiry, nullable `mbid` + `entity_type` columns for future autotagger correlation). `idx_explore_cache_mbid` index. TTLs: 24 h for search, 7 d for entity lookups. Sidebar "Explore" entry with globe icon.
|
||||
- **S02 — Smart search.** Unified `Search()` runs concurrent sub-searches with `WaitGroup` + `Mutex`; frontend renders Top Results / Artists / Albums / Tracks with 300 ms debounce and stale-response discarding via version counter. CAA thumbnails for all result types.
|
||||
- **S03 — Remote artist page.** `explore-artist-details` (904 LOC). Four independent sections via `Promise.allSettled`: header, top tracks (ListenBrainz `TopRecordingsForArtist` with formatted listen counts), discography (grouped Album/EP/Single/Compilation/Other, newest-first), similar artists (recursive navigation). ListenBrainz Labs `SimilarArtists` failures degrade silently to console.error (it's an experimental endpoint).
|
||||
- **S04 — Album detail & release versions.** `explore-album-details` (828 LOC). Release fingerprinting (sort tracks by `(discNumber, position)`, join MBIDs with `|`) collapses identical editions into one cluster; track-count min/max labelling shows differences across editions; default selection is the earliest-dated release. Disc separators when any track has `discNumber > 1`.
|
||||
|
||||
## Known gap (carried into v1.3 follow-up scope)
|
||||
|
||||
- **R032 — offline visual indicator** was in the milestone scope but never implemented. Cache infrastructure works (cached entries served when offline until TTL expires), but `Cache.Get()` doesn't propagate a "from cache" flag to the frontend, and no component renders a "Cached" badge. Functional offline mode works; the UI cue is missing.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **Wrapper types in `backend/explore/types.go` for Wails bindings**, not raw `musicbrainzws2` types. Avoids ugly TS namespaces and serialization issues with unexported fields.
|
||||
- **Dual rate limiters** — accept it. The `musicbrainzws2` library has its own that can't easily be disabled or shared; our limiter governs LB + CAA. Both enforce 1 req/sec independently.
|
||||
- **MBID + entity_type columns in `explore_cache`** — scaffolding for the v1.3 autotagger. No autotagging logic in M004, just storage shape.
|
||||
- **`CoverArtGroupURL` is a synchronous inline string builder.** Pure deterministic template; making it async via Wails would add per-render round-trips for nothing.
|
||||
- **Detail components share design tokens via `tokens.css.ts`** but are independent Lit components — no inheritance, no shared base. Data sources differ entirely (local SQLite vs remote API).
|
||||
@@ -1,20 +0,0 @@
|
||||
# 008 — Autotag: Schema & Grouping Foundation
|
||||
|
||||
> First v1.3 phase. Lays down the schema + bookkeeping for the MusicBrainz autotagger — no scoring, no UI, no MB calls yet. Subsequent autotag phases (009-012) build on this.
|
||||
|
||||
**Shipped:** 2026-04-20 · **Sub-plans:** 008.1-008.4
|
||||
|
||||
## What landed
|
||||
|
||||
- **008.1 — Migration 31: `audio_files.tag_status`.** Text column with inline `CHECK(tag_status IN (...))` constraint (SQLite `ALTER TABLE ADD COLUMN` supports column constraints, so both fresh and upgraded DBs enforce it). Partial index `idx_audio_files_tag_status_untagged ON audio_files(library_id) WHERE tag_status = 'untagged'` powers the pending badge. Backfill sets `user_confirmed` where the joined recording already has an MBID; everything else defaults to `untagged`.
|
||||
- **008.2 — Migration 32: `tagging_items` + `group_key`.** New `tagging_items` table (PK `group_key`, `status CHECK IN ('pending','matched','confirmed','skipped')`, two indexes including the partial `WHERE status = 'pending'` for the badge). `audio_files.group_key TEXT NOT NULL DEFAULT ''` with partial index `WHERE group_key != ''`. Column-referencing indexes live in the migration, not the schema file, because `CREATE TABLE IF NOT EXISTS` is a no-op on existing tables and the partial-index predicate would reference a column that hasn't been added yet.
|
||||
- **008.3 — `backend/autotag.GroupKey` + scan-path integration.** Lower-case hex SHA-1 over `libraryID || 0 || parentDirLower || 0 || albumTrimmed || 0 || discNumber` — intentionally shallow, deeper normalization is scoring territory (009). `saveAudioFile` switched to `CreateAudioFileWithGroupKey` + `UpsertTaggingItemOnTrackAdd`. `updateAudioFileMetadata` rebinds on key change: decrement old group → delete if empty → upsert new group → write new key onto the audio_files row. Migration 32 streams existing rows in batches of 500 and aggregates `tagging_items` at the end, defaulting status to `confirmed` when every track in the group is `user_confirmed`.
|
||||
- **008.4 — Pending-queue sqlc queries.** `CountPendingTaggingItems`, three `ListPendingTaggingItems...` variants (alphabetical, by-score nulls-last, by-recent) — sqlc has no dynamic ORDER BY so each sort has its own query. `CAST(@param AS INTEGER|TEXT)` hints give typed params (otherwise sqlc emits `interface{}`). `GetTaggingItem` and `ListAudioFilesInTaggingGroup` round out the queue API. `EXPLAIN QUERY PLAN` test asserts the badge query uses `idx_tagging_items_status_pending` so a future schema change that breaks the partial index fails loudly.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **Hash algorithm in Go, not SQL.** `autotag.GroupKey` is the single source of truth for the key format so we can evolve it without coupling to SQLite functions.
|
||||
- **SHA-1 over alternatives.** Matches the codebase's existing non-crypto deterministic-key convention. Collision risk at album-group cardinality (millions) is irrelevant.
|
||||
- **Null-byte separators between hash inputs.** Prevents `a|b` vs `ab|` ambiguity.
|
||||
- **Per-track integration inside `commitBatch`'s transaction**, not a post-scan callback — keeps `tagging_items` coherent after partial scans.
|
||||
- **`best_match_release_mbid`, `score`, `last_checked_at` shapes fixed now** even though they stay NULL until 009-010. Avoids schema churn later.
|
||||
@@ -1,32 +0,0 @@
|
||||
# 009 — Autotag: Scoring Engine & MB Orchestration
|
||||
|
||||
> Second v1.3 phase. Given an album-group, produce a ranked list of candidate releases with per-track alignment data, using as few MusicBrainz calls as the API-minimization playbook allows. No UI yet — 010 surfaces this to the user.
|
||||
|
||||
**Shipped:** 2026-04-21 · **Sub-plans:** types + normalization + distance, local resolver, MB orchestration, ranker + persistence, test corpus
|
||||
|
||||
## What landed
|
||||
|
||||
- **`backend/autotag/` domain types** — `Candidate`, `TrackAlignment`, `GroupScore`, `LocalTrack`, `CandidateSource` (`local` / `musicbrainz`), `AlignmentStatus` (`matched` / `missing` / `extra` / `mismatched`).
|
||||
- **`Normalize(s)`** — Unicode NFC → qualifier-suffix strip (`(Remastered 2009)`, `[Bonus Track]`, `(feat. X)`, etc.) → case fold → punctuation drop → whitespace collapse. Comparison-only, not human-readable.
|
||||
- **Per-track distance** — weighted 60% title similarity (1.0 − Levenshtein/max-len), 30% length delta (linear: ≤1 s = 1.0, ≥30 s = 0.0, neutral 0.5 when either side unknown), 10% track-number match.
|
||||
- **Greedy alignment** — `AlignTracks` picks the highest-scoring (local, cand) pair iteratively; not Hungarian-optimal but fine at album cardinalities. Emits `matched` / `missing` / `extra` / `mismatched` rows so the review UI can render the diff.
|
||||
- **Local-first resolver** — `ListLocalReleaseGroupCandidates` sqlc query pre-filters on `rg.mbid != '' AND r.mbid != '' AND rg.name = ? COLLATE NOCASE`, then Go applies full normalization. Candidate track lists only include recordings that themselves have MBIDs (avoids untagged dupes polluting the "canonical" view when the same `(name, artist)` release group is shared across libraries).
|
||||
- **MB orchestration** — `MBClient` interface (`SearchReleaseGroups`, `BrowseReleases`, `LookupArtist`) hides `explore.MusicBrainzClient`; `backend/explore/autotagclient.go` adapts one to the other. `buildMBQuery` assembles `release:"X" AND arid:<mbid> AND tracks:N` — `arid:` makes the search cache key deterministic, `tracks:N` filters out box-set-style releases. One search per album + one `BrowseReleases` per candidate RG.
|
||||
- **Release-level ranker** — aggregate track score (70%) + track-count match (15%, zero at ≥50% delta) + meta (15%, avg of year/Official/country bonuses). `Scorer.ScoreGroup` hits local first, runs MB only when no local candidate scores ≥ 0.90.
|
||||
- **Persistence** — `SetTaggingItemBestMatch` writes `best_match_release_mbid`, `score`, `last_checked_at = CURRENT_TIMESTAMP`, `status = 'matched'`.
|
||||
|
||||
## Key decisions retained
|
||||
|
||||
- **SHA-1-grade normalization vs. full MB-equivalent.** Qualifier regex handles the common cases (remaster, deluxe, explicit, feat., bonus, etc.) without dragging in a full MB title-parsing library. Edges that bite in real libraries will show up in 010 review UX and can be patched then.
|
||||
- **`*sqlcgen.Queries` as the DB boundary for autotag**, not `*database.DB`. The `database` package already depends on `autotag.GroupKey` (from 008.3), so reversing the dependency via `database` would create a cycle. Using `sqlcgen` directly is acyclic and keeps `autotag` swappable.
|
||||
- **Scorer constructor takes `MBClient` as interface, not `*explore.MusicBrainzClient`.** Lets tests inject a stub without spinning up the HTTP + cache layer. The concrete adapter lives in `explore/autotagclient.go`.
|
||||
- **`localSufficient = 0.90` threshold for skipping MB.** Empirical guess — will get retuned in 011 auto-accept phase when we observe real corpus scores.
|
||||
- **Weights `60/30/10` for title/length/track-number.** Cribbed from beets' broad intuition; tuned to emphasize title-matching since length data can be unreliable from Vorbis Comments. Tests document the expected floors (e.g. "exact match should score ≥ 0.99") so nudging weights won't silently regress.
|
||||
- **`release_groups` and `recordings` must both carry MBIDs** for a local candidate. Otherwise untagged dupes of the same album (across libraries) falsely expand the "canonical" track list.
|
||||
- **UTF-8 em-dashes in SQL comments broke sqlc's string-literal emitter**, truncating generated query strings mid-word. All autotag SQL comments use ASCII punctuation.
|
||||
|
||||
## Known follow-ups into 010+
|
||||
|
||||
- **`guessArtistMBID` is a stub** returning `""` because `LocalTrack` doesn't currently carry artist MBIDs. 010 should thread artist MBIDs through `ListAudioFilesInTaggingGroup` so the MB resolver can use `arid:` filters.
|
||||
- **`yearBonus` uses `time.Now().Year()`** as a placeholder target. Should become the earliest release-date hint from the group's tracks once 010 provides it.
|
||||
- **VA compilation detection threshold** is still open (noted in `.planning/NOTES.md`). The scorer doesn't special-case per-track artist credits differing from album-artist.
|
||||
@@ -1,26 +0,0 @@
|
||||
# 011 — Autotag: Auto-Accept & Entry Points
|
||||
|
||||
> Fourth v1.3 phase. The strict all-match auto-accept path runs as a background job; the tool is reachable from every place a user expects.
|
||||
|
||||
**Status:** pending · **Requirements:** AUTO-01..06 · **Depends on:** 010 (needs the apply pipeline)
|
||||
|
||||
## Success criteria
|
||||
|
||||
1. An album-group qualifies for auto-accept iff: exact track-count match, every track's normalized title matches, every track's length within ±2s, no cover-art replacement required, no existing-MBID conflicts. Decision uses already-cached candidate data — **no additional MB calls**.
|
||||
2. Auto-accept job processes all qualifying groups in the queue, emits progress events, is cancellable at any point, honors the shared rate limiter.
|
||||
3. Right-click on a track / album / artist exposes "Autotag this album" (queues + jumps to review) and "Retag" (flips status to `untagged` and requeues).
|
||||
4. After a library scan finishes with N new untagged albums, a non-blocking toast appears linking to `/autotag`.
|
||||
5. Sidebar has an "Autotag" nav entry with a pending-count badge, updates reactively.
|
||||
6. Pasting a MB release URL into the Paste-URL dialog renders a full diff against the current album with one `LookupRelease` call.
|
||||
|
||||
## Sub-plans
|
||||
|
||||
- Strict all-match rule + unit tests.
|
||||
- Auto-accept background job — progress events, cancellation, queue traversal.
|
||||
- Context menu integrations on track/album/artist views.
|
||||
- Post-scan toast wiring via the existing scan-complete event.
|
||||
- Sidebar nav entry + pending-count badge store integration.
|
||||
|
||||
## Risk callout
|
||||
|
||||
Release selection can pick the wrong edition. The exact-track-count gate prevents most silent misbehavior, but bonus-track editions and remaster reissues with matching track counts are genuine ambiguity. Manual review handles the edge cases — that's why auto-accept is strict by design, and the slider for fuzzy auto-accept is explicitly out of scope.
|
||||
@@ -1,31 +0,0 @@
|
||||
# 012 — Autotag: Settings & Polish
|
||||
|
||||
> Fifth and final v1.3 phase. Configuration surfaces in the existing settings system; the known sharp edges (rate-limit contention, VA compilations, singleton files) get sanded; the fingerprinting seam is in place for the future.
|
||||
|
||||
**Status:** pending · **Requirements:** CFG-01..06 · **Depends on:** 011
|
||||
|
||||
## Success criteria
|
||||
|
||||
1. Autotag settings panel accessible via the existing templ/HTMX settings UI. Exposes: enable/disable auto-accept, per-library file-write warning reset, default review filter, default sort order.
|
||||
2. Shared rate limiter distinguishes interactive from background requests. User-initiated MB calls (paste-URL, opening a review, explore browsing) are never blocked behind a running auto-accept job.
|
||||
3. VA compilation albums (per-track artist credits differ from album-artist) are detected. The auto-accept artist-match rule relaxes for them; the ranker prefers MB releases credited to "Various Artists".
|
||||
4. Singleton files (`track_count = 1`, no sibling context) use a recording-level match path (`SearchRecordings` with title + artist + length filters). Lower confidence ceiling — never eligible for auto-accept regardless of confidence.
|
||||
5. `type Identifier interface { Identify(path) ([]Candidate, error) }` exists with `MetadataIdentifier` as the v1 implementation. No fpcalc integration, but the seam is in place for a future `AcoustIDIdentifier`.
|
||||
6. User-facing quickstart docs exist; CLAUDE.md gets a `backend/autotag/` package description; scoring-function dev notes are committed.
|
||||
|
||||
## Sub-plans
|
||||
|
||||
- Autotag settings panel (templ + HTMX).
|
||||
- Rate-limiter priority support.
|
||||
- VA compilation detection and scoring adjustments.
|
||||
- Singleton-file match path.
|
||||
- `Identifier` interface seam with `MetadataIdentifier`.
|
||||
- Docs — user quickstart + dev notes + CLAUDE.md update.
|
||||
|
||||
## Ship criteria for v1.3 overall
|
||||
|
||||
- All 29 SCHEMA/MATCH/REVIEW/AUTO/CFG requirements complete.
|
||||
- All five phases' success criteria verified end-to-end on a real library (10k+ tracks, mixed match quality).
|
||||
- Auto-accept run against a well-tagged subset produces zero incorrect matches.
|
||||
- Manual review workflow can process 100 albums in under 30 minutes without mouse use.
|
||||
- Recap files moved to `.planning/plans/completed/`.
|
||||
@@ -1,61 +1,77 @@
|
||||
# YellowJacket
|
||||
|
||||
[](https://github.com/onion-4-dinner/yellowjacket/actions/workflows/ci.yml)
|
||||
[](https://github.com/onion-4-dinner/yellowjacket/actions/workflows/release.yml)
|
||||
*Music how it was meant to bee.*
|
||||
|
||||
Music how it was meant to bee.
|
||||
YellowJacket is a fast, cross-platform desktop music player for your local
|
||||
collection. It plays your files, keeps your library tidy, and helps you discover
|
||||
and organize your music — all in a clean, responsive interface. No accounts, no
|
||||
streaming, no telemetry: just your music on your machine.
|
||||
|
||||
YellowJacket is a cross-platform desktop music player built with Go and web technologies. It focuses on local music library management with a clean, responsive interface.
|
||||
Runs on **Linux**, **macOS**, and **Windows**.
|
||||
|
||||
## Features
|
||||
|
||||
### Play your music
|
||||
- Plays **MP3, FLAC, OGG Vorbis, and WAV**
|
||||
- Play, pause, seek, and volume control with a mute toggle
|
||||
- Gapless, glitch-free seeking backed by a read-ahead buffer
|
||||
- A queue you can add to, reorder, and shuffle, with play-next support
|
||||
- Shuffle and repeat (off / all / one)
|
||||
- Picks up right where you left off — remembers your track, position, and volume between sessions
|
||||
- Media-key and MPRIS support on Linux, so your desktop's playback controls just work
|
||||
|
||||
### Keep your library organized
|
||||
- Point it at your music folders and it scans them automatically
|
||||
- Reads tags and embedded cover art, and de-duplicates artwork so it isn't stored twice
|
||||
- Incremental sync — only new or changed files get reprocessed, and deleted files are cleaned up
|
||||
- Browse by **album**, **artist**, or **genre**, or search across everything
|
||||
- Mark favorites and see what you've been listening to with play history
|
||||
- Edit track tags directly when something's off
|
||||
|
||||
### Playlists
|
||||
- Create playlists, drag tracks in, and reorder them
|
||||
- **Smart playlists** that build themselves from rules (by genre, rating, play count, and more)
|
||||
- Pin a default playlist and spot duplicate tracks at a glance
|
||||
|
||||
### Discover and clean up (powered by MusicBrainz)
|
||||
- **Explore** — browse artists, releases, and genres from the MusicBrainz catalog, not just what's already in your library
|
||||
- **Auto-tag** — match your files against MusicBrainz to fill in correct artist, album, and track metadata, with a review step before anything is written
|
||||
- **Lyrics search** — find a track by a line you remember
|
||||
|
||||
## Install
|
||||
|
||||
Grab the latest release for your platform:
|
||||
Download the latest build for your platform from the
|
||||
[releases page](https://git.ljones.me/yonlu/yellowjacket/releases).
|
||||
|
||||
**[Download Latest Release](https://github.com/onion-4-dinner/yellowjacket/releases/latest)**
|
||||
|
||||
| Platform | Binary |
|
||||
|----------|--------|
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| Linux | `yellowjacket-linux-amd64` |
|
||||
| macOS | `yellowjacket-darwin-universal.app.zip` (Apple Silicon + Intel) |
|
||||
| Windows | `yellowjacket-windows-amd64.exe` |
|
||||
|
||||
## Features
|
||||
Prefer to build it yourself? See [Building from source](#building-from-source).
|
||||
|
||||
**Playback**
|
||||
- Play, pause, seek, and volume control with mute toggle
|
||||
- Support for MP3, FLAC, OGG Vorbis, and WAV
|
||||
- Queue management with add, remove, reorder, and play-next
|
||||
- Shuffle mode (Fisher-Yates) and repeat modes (off, all, one)
|
||||
- Session persistence -- resumes volume, track, and seek position on restart
|
||||
## Getting started
|
||||
|
||||
**Library**
|
||||
- Concurrent library scanning with automatic metadata extraction
|
||||
- ID3v2, Vorbis Comments, and other tag format support
|
||||
- Embedded cover art extraction with content-hash deduplication
|
||||
- Incremental sync -- only processes new or changed files
|
||||
- Orphan cleanup for deleted files
|
||||
1. Launch YellowJacket.
|
||||
2. Open **Settings** and add the folder(s) where your music lives.
|
||||
3. Let the initial scan finish — you'll see progress as it works.
|
||||
4. Browse by album, artist, or genre, queue something up, and press play.
|
||||
|
||||
**Interface**
|
||||
- Album cover grid view and track list view
|
||||
- Now playing display with cover art
|
||||
- Resizable sidebar navigation
|
||||
- Slide-out queue panel
|
||||
- Context menus for tracks and albums (play, add to queue, play next)
|
||||
- Settings page for library directory configuration
|
||||
Your library and settings are stored locally:
|
||||
|
||||
## Architecture
|
||||
| | Linux / macOS | Windows |
|
||||
|---|---|---|
|
||||
| Config | `~/.config/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\config` |
|
||||
| Library data | `~/.local/share/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\data` |
|
||||
|
||||
YellowJacket uses the [Wails v2](https://wails.io/) framework to bridge a Go backend with a TypeScript/[Lit](https://lit.dev/) frontend running in a native webview.
|
||||
## Building from source
|
||||
|
||||
- **Go backend** -- audio decoding and playback ([beep](https://github.com/gopxl/beep)), library scanning, SQLite database, cover art serving, TOML configuration
|
||||
- **TypeScript frontend** -- Lit web components, singleton stores with reactive controllers, [Web Awesome](https://www.webawesome.com/) UI components
|
||||
- **Communication** -- bidirectional event system via Wails runtime; backend is the source of truth
|
||||
- **Database** -- SQLite (pure-Go driver) with type-safe queries generated by [sqlc](https://sqlc.dev/); MusicBrainz-style data model (artists, artist credits, recordings, release groups)
|
||||
- **Config page** -- HTMX-based, loads HTML fragments rendered by Go [templ](https://templ.guide/) templates
|
||||
YellowJacket is built with [Go](https://go.dev/) and a
|
||||
[Lit](https://lit.dev/)/TypeScript frontend, bridged by the
|
||||
[Wails](https://wails.io/) framework.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
**Prerequisites**
|
||||
|
||||
| Tool | Version |
|
||||
|------|---------|
|
||||
@@ -64,66 +80,22 @@ YellowJacket uses the [Wails v2](https://wails.io/) framework to bridge a Go bac
|
||||
| pnpm | 10+ |
|
||||
| Wails CLI | v2 (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`) |
|
||||
|
||||
**Linux system dependencies:**
|
||||
On Linux, install the system libraries Wails needs:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libasound2-dev libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
```
|
||||
|
||||
On macOS and Windows, no additional system dependencies are needed. Run `wails doctor` to verify your environment.
|
||||
macOS and Windows need no extra system packages. Run `wails doctor` to check your
|
||||
environment.
|
||||
|
||||
### Build & Run
|
||||
**Build**
|
||||
|
||||
```bash
|
||||
make setup # Install git hooks (lefthook)
|
||||
make dev # Development with hot-reload
|
||||
make build-dev # Debug build
|
||||
make build-prod # Production build (obfuscated + UPX compressed)
|
||||
make generate # Run code generators (sqlc, templ)
|
||||
make setup # install tooling and git hooks
|
||||
make dev # run with hot-reload
|
||||
make build-prod # produce a release binary
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
make test # All tests (race detector, no cache, 2min timeout)
|
||||
|
||||
# Run tests manually (build tag required):
|
||||
go test -tags webkit2_41 ./backend/player/ # Single package
|
||||
go test -tags webkit2_41 -run TestFunctionName ./backend/player/ # Single test
|
||||
go test -tags webkit2_41 -v -run TestFunctionName ./backend/player/ # Verbose
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
make lint # golangci-lint (v2 config, strict rules)
|
||||
```
|
||||
|
||||
### Data Locations
|
||||
|
||||
| | Linux | macOS | Windows |
|
||||
|---|---|---|---|
|
||||
| Config | `~/.config/yellowjacket/` | `~/.config/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\config` |
|
||||
| Data/DB | `~/.local/share/yellowjacket/` | `~/.local/share/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\data` |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
backend/ Go backend
|
||||
player/ Audio playback (beep)
|
||||
queue/ Queue management, shuffle, repeat
|
||||
library/ Library scanning, metadata extraction, cover art
|
||||
metadata/ Audio decoding and tag extraction
|
||||
database/ SQLite connection, sqlc-generated queries
|
||||
config/ TOML config, HTTP handler for settings page
|
||||
events/ Event name constants (mirrored in frontend)
|
||||
models/ Shared data types (Album, Track, Artist)
|
||||
system/ OS-specific paths
|
||||
frontend/src/ TypeScript/Lit frontend
|
||||
components/ UI components (player, sidebar, track list, cover grid, queue)
|
||||
store/ Singleton stores and reactive controllers
|
||||
pages/ Config page (HTMX entry point)
|
||||
internal/dev/ Build-tag dev/prod detection
|
||||
test_data/ Audio test fixtures
|
||||
```
|
||||
|
||||
Further development documentation is available in [`docs/dev/`](./docs/dev/overview.md).
|
||||
More detail for contributors lives in
|
||||
[`docs/dev/overview.md`](./docs/dev/overview.md) and [`CLAUDE.md`](./CLAUDE.md).
|
||||
|
||||
+54
-10
@@ -247,20 +247,28 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
RepopulatePlaylists: yj.playlist.RepopulateFromM3U,
|
||||
ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan,
|
||||
OnAllScansComplete: func() {
|
||||
// Index new library artists (blocks until done).
|
||||
yj.explore.IndexNewArtists()
|
||||
yj.explore.WaitForIndexIdle()
|
||||
|
||||
// Populate local_*_id cross-reference columns on
|
||||
// explore_index so "is this in my library?" is O(1).
|
||||
// Fold the local library into the search index: every
|
||||
// MB-verified owned artist/album/track is upserted and
|
||||
// flagged in_library, straight from the library tables
|
||||
// with no API calls. Deep discographies stay lazy.
|
||||
yj.explore.PopulateLocalCrossReferences()
|
||||
|
||||
// Always start the full build — it's incremental and
|
||||
// will skip tiers that are already fresh. This ensures
|
||||
// sitewide + similar artist tiers run even if the index
|
||||
// already has library data.
|
||||
// Start (or resume) the dump-based index build. Skips
|
||||
// itself once the one-time import has completed, so this
|
||||
// is cheap on every startup.
|
||||
yj.explore.StartIndexBuild()
|
||||
|
||||
// Fold in any new incremental listen dumps to keep
|
||||
// popularity fresh (weekly-gated, background, no API).
|
||||
// No-op while the full import above is still running.
|
||||
yj.explore.RefreshListenCounts()
|
||||
|
||||
// Refresh the lyric-search FTS index from the just-scanned
|
||||
// library, then backfill any missing lyrics from LRCLIB in
|
||||
// the background (bounded, resumable, idempotent).
|
||||
yj.explore.RebuildLyricsIndex()
|
||||
yj.explore.BackfillLibraryLyrics()
|
||||
|
||||
// Sweep the autotag queue for newly-discovered pending
|
||||
// items so the user sees match scores ready when they
|
||||
// next open the review page. The worker is idempotent
|
||||
@@ -276,6 +284,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
yj.library.SetRemovalHooks(library.RemovalHooks{
|
||||
StopPlayback: func() { yj.player.UnloadTrack() },
|
||||
CompactQueue: yj.queue.CompactAfterLibraryRemoval,
|
||||
// Removal deletes owned content outside a scan, so force the
|
||||
// gated library-sync steps to re-run on the next launch and
|
||||
// clear stale in_library flags / orphaned lyric-index rows.
|
||||
PostRemove: yj.explore.InvalidateLibrarySync,
|
||||
})
|
||||
|
||||
// Register playback finished handler to drive queue auto-advance.
|
||||
@@ -333,6 +345,21 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
||||
w, h := wailsruntime.WindowGetSize(ctx)
|
||||
|
||||
// Guard against a bogus size clobbering a good saved one. During
|
||||
// teardown / hot-reload the runtime can report a zero or below-
|
||||
// minimum size; persisting that would shrink the window to the
|
||||
// minimum on next launch. Keep the previously-saved size instead.
|
||||
if w < config.MinWidth || h < config.MinHeight {
|
||||
yj.logger.Warn("OnBeforeClose: ignoring bogus window size",
|
||||
"width", w,
|
||||
"height", h,
|
||||
"kept_width", yj.appConfig.Window.Width,
|
||||
"kept_height", yj.appConfig.Window.Height,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
yj.logger.Info("OnBeforeClose: saving window state",
|
||||
"width", w,
|
||||
"height", h,
|
||||
@@ -393,7 +420,24 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
||||
// index build directly. If scans WERE queued, the
|
||||
// OnAllScansComplete hook starts it after they finish.
|
||||
if yj.library.GetScanQueueLength() == 0 && !yj.library.IsScanActive() {
|
||||
// Keep the search index's in_library flags and owned-entity
|
||||
// rows in sync. Gated: the library is unchanged here, so
|
||||
// this only does work on the first launch after an upgrade
|
||||
// or index wipe — steady-state launches skip the write burst.
|
||||
yj.explore.PopulateLocalCrossReferencesIfNeeded()
|
||||
|
||||
yj.explore.StartIndexBuild()
|
||||
|
||||
// Weekly-gated incremental popularity refresh (background,
|
||||
// no API). No-op while the full import is running.
|
||||
yj.explore.RefreshListenCounts()
|
||||
|
||||
// Same for the lyric-search index. The backfill keeps it in
|
||||
// sync incrementally, so on an unchanged library the full
|
||||
// rebuild is redundant and gated out; the backfill still runs
|
||||
// to fill any remaining gaps.
|
||||
yj.explore.RebuildLyricsIndexIfNeeded()
|
||||
yj.explore.BackfillLibraryLyrics()
|
||||
}
|
||||
|
||||
// Kick off the autotag prefetch worker so any unscored
|
||||
|
||||
+114
-62
@@ -1,5 +1,7 @@
|
||||
package autotag
|
||||
|
||||
import "slices"
|
||||
|
||||
// alignTitleFloor is the minimum title similarity required to
|
||||
// pair a local file with a candidate track at all. Below this,
|
||||
// even the best available pair is treated as no pair: the local
|
||||
@@ -10,14 +12,28 @@ package autotag
|
||||
// happens to share a track number stays in its own group.
|
||||
const alignTitleFloor = 0.30
|
||||
|
||||
// AlignTracks pairs local tracks with candidate tracks using a
|
||||
// greedy best-score algorithm: repeatedly pick the (local, cand)
|
||||
// pair with the highest trackDistance that hasn't already been
|
||||
// claimed. Not optimal (Hungarian would be), but good enough for
|
||||
// the small cardinalities we see (album tracks, ~10-50) and much
|
||||
// simpler.
|
||||
// alignPair carries the per-combination scores AlignTracks computes
|
||||
// once per (local, candidate) pair.
|
||||
type alignPair struct {
|
||||
li int
|
||||
ci int
|
||||
score float64
|
||||
title float64
|
||||
length float64
|
||||
}
|
||||
|
||||
// AlignTracks pairs local tracks with candidate tracks in two
|
||||
// passes:
|
||||
//
|
||||
// Pairs whose title similarity is below alignTitleFloor are
|
||||
// 1. Recording-MBID locks: a local track whose RecordingMBID equals
|
||||
// a candidate track's MBID is the same recording by definition —
|
||||
// it pairs unconditionally, regardless of how the titles compare.
|
||||
// 2. Greedy best-score: repeatedly pick the remaining (local, cand)
|
||||
// pair with the highest track score. Not optimal (Hungarian
|
||||
// would be), but good enough for the small cardinalities we see
|
||||
// (album tracks, ~10-50) and much simpler.
|
||||
//
|
||||
// Greedy pairs whose title similarity is below alignTitleFloor are
|
||||
// rejected: the local stays "unmatched" and the candidate slot
|
||||
// surfaces as "missing". This lets a wrong-track-number-but-
|
||||
// matching-title file pair correctly while keeping a totally
|
||||
@@ -25,44 +41,72 @@ const alignTitleFloor = 0.30
|
||||
//
|
||||
// Returns one TrackAlignment per local track (status = matched,
|
||||
// mismatched, or unmatched) plus additional missing alignments
|
||||
// for candidate tracks with no local file. The caller sums Score
|
||||
// fields to get a release-level score.
|
||||
// for candidate tracks with no local file.
|
||||
func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
|
||||
type pair struct {
|
||||
li int
|
||||
ci int
|
||||
score float64
|
||||
title float64
|
||||
localUsed := make([]bool, len(locals))
|
||||
candUsed := make([]bool, len(cands))
|
||||
alignments := make([]TrackAlignment, len(locals))
|
||||
localMatched := 0
|
||||
|
||||
// Pass 1: recording-MBID locks.
|
||||
for li, local := range locals {
|
||||
if local.RecordingMBID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for ci, cand := range cands {
|
||||
if candUsed[ci] || cand.MBID == "" || cand.MBID != local.RecordingMBID {
|
||||
continue
|
||||
}
|
||||
|
||||
localUsed[li] = true
|
||||
candUsed[ci] = true
|
||||
localMatched++
|
||||
alignments[li] = mkAlignment(li, local, cand, alignPair{
|
||||
title: titleSimilarity(local.Title, cand.Title),
|
||||
length: lengthScore(local.LengthMillis, cand.LengthMillis),
|
||||
}, true)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Score every (local, cand) combination.
|
||||
pairs := make([]pair, 0, len(locals)*len(cands))
|
||||
// Pass 2: greedy best-score over the remaining combinations.
|
||||
pairs := make([]alignPair, 0, len(locals)*len(cands))
|
||||
|
||||
for li, local := range locals {
|
||||
if localUsed[li] {
|
||||
continue
|
||||
}
|
||||
|
||||
for ci, cand := range cands {
|
||||
pairs = append(pairs, pair{
|
||||
li: li,
|
||||
ci: ci,
|
||||
score: trackDistance(local, cand),
|
||||
title: titleSimilarity(local.Title, cand.Title),
|
||||
if candUsed[ci] {
|
||||
continue
|
||||
}
|
||||
|
||||
title := titleSimilarity(local.Title, cand.Title)
|
||||
length := lengthScore(local.LengthMillis, cand.LengthMillis)
|
||||
pairs = append(pairs, alignPair{
|
||||
li: li,
|
||||
ci: ci,
|
||||
score: combineTrackScore(title, length, trackNumberOK(local, cand)),
|
||||
title: title,
|
||||
length: length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending by score — pick best pairs first. Insertion
|
||||
// sort keeps the dependency surface zero; N^2 here is fine for
|
||||
// album-sized inputs.
|
||||
for i := 1; i < len(pairs); i++ {
|
||||
for j := i; j > 0 && pairs[j].score > pairs[j-1].score; j-- {
|
||||
pairs[j], pairs[j-1] = pairs[j-1], pairs[j]
|
||||
// Sort descending by score — pick best pairs first.
|
||||
slices.SortStableFunc(pairs, func(a, b alignPair) int {
|
||||
switch {
|
||||
case a.score > b.score:
|
||||
return -1
|
||||
case a.score < b.score:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
localUsed := make([]bool, len(locals))
|
||||
candUsed := make([]bool, len(cands))
|
||||
alignments := make([]TrackAlignment, len(locals))
|
||||
|
||||
localMatched := 0
|
||||
})
|
||||
|
||||
for _, p := range pairs {
|
||||
if localMatched == len(locals) {
|
||||
@@ -84,34 +128,7 @@ func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
|
||||
localUsed[p.li] = true
|
||||
candUsed[p.ci] = true
|
||||
localMatched++
|
||||
|
||||
l := locals[p.li]
|
||||
c := cands[p.ci]
|
||||
|
||||
status := AlignmentMatched
|
||||
if p.title < titleReject {
|
||||
status = AlignmentMismatched
|
||||
}
|
||||
|
||||
delta := l.LengthMillis - c.LengthMillis
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
|
||||
alignments[p.li] = TrackAlignment{
|
||||
LocalIndex: p.li,
|
||||
LocalTitle: l.Title,
|
||||
LocalLengthMillis: l.LengthMillis,
|
||||
CandidatePosition: c.Position,
|
||||
CandidateDiscNumber: c.DiscNumber,
|
||||
CandidateTitle: c.Title,
|
||||
CandidateMBID: c.MBID,
|
||||
CandidateLength: c.LengthMillis,
|
||||
TitleScore: p.title,
|
||||
LengthDeltaMs: delta,
|
||||
TrackNumberOK: l.TrackNumber > 0 && l.TrackNumber == c.Position,
|
||||
Status: status,
|
||||
}
|
||||
alignments[p.li] = mkAlignment(p.li, locals[p.li], cands[p.ci], p, false)
|
||||
}
|
||||
|
||||
// Local tracks left unclaimed → folder has them, candidate
|
||||
@@ -151,3 +168,38 @@ func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
|
||||
|
||||
return alignments
|
||||
}
|
||||
|
||||
// mkAlignment builds the matched/mismatched alignment for one
|
||||
// claimed (local, candidate) pair. idMatch pairs are always
|
||||
// "matched" — same recording MBID means same recording, however
|
||||
// the titles are spelled.
|
||||
func mkAlignment(
|
||||
li int, l LocalTrack, c CandidateTrack, p alignPair, idMatch bool,
|
||||
) TrackAlignment {
|
||||
status := AlignmentMatched
|
||||
if !idMatch && p.title < titleReject {
|
||||
status = AlignmentMismatched
|
||||
}
|
||||
|
||||
delta := l.LengthMillis - c.LengthMillis
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
|
||||
return TrackAlignment{
|
||||
LocalIndex: li,
|
||||
LocalTitle: l.Title,
|
||||
LocalLengthMillis: l.LengthMillis,
|
||||
CandidatePosition: c.Position,
|
||||
CandidateDiscNumber: c.DiscNumber,
|
||||
CandidateTitle: c.Title,
|
||||
CandidateMBID: c.MBID,
|
||||
CandidateLength: c.LengthMillis,
|
||||
TitleScore: p.title,
|
||||
LengthScore: p.length,
|
||||
LengthDeltaMs: delta,
|
||||
TrackNumberOK: trackNumberOK(l, c),
|
||||
IDMatch: idMatch,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
+164
-27
@@ -1,5 +1,11 @@
|
||||
package autotag
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// levenshtein returns the Levenshtein edit distance between a and
|
||||
// b, operating on runes so multi-byte characters count as one edit.
|
||||
// Allocates a single O(min(len)) scratch slice.
|
||||
@@ -63,29 +69,142 @@ func min3(a, b, c int) int {
|
||||
return c
|
||||
}
|
||||
|
||||
// titleSimilarity returns a score in [0, 1] from normalized edit
|
||||
// distance. 1.0 means identical after normalization, 0.0 means
|
||||
// fully dissimilar. Both sides are normalized inside.
|
||||
func titleSimilarity(a, b string) float64 {
|
||||
na := Normalize(a)
|
||||
nb := Normalize(b)
|
||||
// sdPattern couples a regexp with the weight its removal carries in
|
||||
// stringDist: when deleting the matched portion from both strings
|
||||
// shrinks their distance, the recovered distance is re-added at
|
||||
// `weight` instead of counting fully. Weight 0 makes a difference
|
||||
// in that portion free (known-cosmetic); higher weights make it
|
||||
// cheap but not free. Modeled on beets' SD_PATTERNS.
|
||||
type sdPattern struct {
|
||||
re *regexp.Regexp
|
||||
weight float64
|
||||
}
|
||||
|
||||
if na == "" && nb == "" {
|
||||
return 1.0
|
||||
}
|
||||
// sdPatterns are applied in order; earlier patterns claim their
|
||||
// portion of the string first. Known qualifiers (whitelist) are
|
||||
// free; generic parenthetical / bracketed content, featured-artist
|
||||
// credits, leading articles, and part suffixes are de-weighted.
|
||||
var sdPatterns = []sdPattern{
|
||||
{qualifierPattern, 0.0},
|
||||
{dashQualifierPattern, 0.0},
|
||||
{regexp.MustCompile(`^the `), 0.1},
|
||||
{regexp.MustCompile(`\b(featuring|feat\.?|ft\.?)[ :].*$`), 0.1},
|
||||
{regexp.MustCompile(`\(.*?\)`), 0.3},
|
||||
{regexp.MustCompile(`\[.*?\]`), 0.3},
|
||||
{regexp.MustCompile(`(, )?\b(pt\.|part) .+$`), 0.2},
|
||||
}
|
||||
|
||||
longest := len(na)
|
||||
if len(nb) > longest {
|
||||
longest = len(nb)
|
||||
}
|
||||
// sdEndWords are articles that user tags sometimes rotate to the
|
||||
// end with a comma: "Beatles, The" ≡ "The Beatles".
|
||||
var sdEndWords = []string{"the", "a", "an"}
|
||||
|
||||
if longest == 0 {
|
||||
// stringDistBasic is the normalized edit distance between the two
|
||||
// strings reduced to lowercase alphanumerics, in [0, 1]. Inputs
|
||||
// are assumed to be lowercased and ASCII-folded already.
|
||||
func stringDistBasic(a, b string) float64 {
|
||||
a = alnumOnly(a)
|
||||
b = alnumOnly(b)
|
||||
|
||||
if a == "" && b == "" {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
dist := levenshtein(na, nb)
|
||||
longest := max(len(a), len(b))
|
||||
|
||||
return 1.0 - float64(dist)/float64(longest)
|
||||
return float64(levenshtein(a, b)) / float64(longest)
|
||||
}
|
||||
|
||||
// alnumOnly strips everything but letters and digits.
|
||||
func alnumOnly(s string) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.Grow(len(s))
|
||||
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// stringDist returns an "intuitive" distance between two titles or
|
||||
// artist credits, in [0, 1]. It is a normalized edit distance with
|
||||
// tweaks reflecting how music metadata actually differs (ported
|
||||
// from beets' string_dist):
|
||||
//
|
||||
// - accents transliterated, case ignored
|
||||
// - "X, The" rotated back to "The X" (same for "A"/"An")
|
||||
// - "&" ≡ "and"
|
||||
// - known qualifier suffixes ("(Remastered 2009)", "- Radio Edit")
|
||||
// are free; unknown parenthesized/bracketed content, featured-
|
||||
// artist credits, leading articles, and "Part N" suffixes are
|
||||
// de-weighted rather than counting as full edits
|
||||
func stringDist(a, b string) float64 {
|
||||
a = strings.ToLower(asciiFold(a))
|
||||
b = strings.ToLower(asciiFold(b))
|
||||
|
||||
a = rotateEndWord(a)
|
||||
b = rotateEndWord(b)
|
||||
|
||||
a = strings.ReplaceAll(a, "&", " and ")
|
||||
b = strings.ReplaceAll(b, "&", " and ")
|
||||
|
||||
base := stringDistBasic(a, b)
|
||||
penalty := 0.0
|
||||
|
||||
for _, p := range sdPatterns {
|
||||
ca := p.re.ReplaceAllString(a, "")
|
||||
cb := p.re.ReplaceAllString(b, "")
|
||||
|
||||
if ca == a && cb == b {
|
||||
continue
|
||||
}
|
||||
|
||||
// The pattern was present: measure how much of the distance
|
||||
// it accounted for and re-add that share at reduced weight.
|
||||
caseDist := stringDistBasic(ca, cb)
|
||||
|
||||
delta := base - caseDist
|
||||
if delta <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
a, b = ca, cb
|
||||
base = caseDist
|
||||
penalty += p.weight * delta
|
||||
}
|
||||
|
||||
return base + penalty
|
||||
}
|
||||
|
||||
// rotateEndWord undoes sort-style article rotation: "beatles, the"
|
||||
// → "the beatles". Input must be lowercased.
|
||||
func rotateEndWord(s string) string {
|
||||
for _, w := range sdEndWords {
|
||||
suffix := ", " + w
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
return w + " " + s[:len(s)-len(suffix)]
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// titleSimilarity returns a score in [0, 1] from stringDist. 1.0
|
||||
// means identical after normalization, 0.0 means fully dissimilar.
|
||||
func titleSimilarity(a, b string) float64 {
|
||||
if strings.TrimSpace(a) == "" && strings.TrimSpace(b) == "" {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
sim := 1.0 - stringDist(a, b)
|
||||
if sim < 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return sim
|
||||
}
|
||||
|
||||
// Scoring weights for the per-track distance function. Local reads
|
||||
@@ -98,13 +217,17 @@ const (
|
||||
|
||||
// Length deltas at or below lengthExactMs score 1.0 — matches
|
||||
// the frontend's "subtle drift" threshold so anything the UI
|
||||
// hides also doesn't count against the score. Past the
|
||||
// threshold the penalty scales with delta / candidateMs (i.e.
|
||||
// percentage of candidate-track length): a 5 s delta on a
|
||||
// hides also doesn't count against the score. MB recording
|
||||
// lengths routinely differ from file durations by a few seconds
|
||||
// (encoder padding, different masters), so the grace band is
|
||||
// deliberately wider than perceptual accuracy; Picard tolerates
|
||||
// up to 30 s linearly and beets grants a flat 10 s grace. Past
|
||||
// the threshold the penalty scales with delta / candidateMs
|
||||
// (i.e. percentage of candidate-track length): a 5 s delta on a
|
||||
// 4 min track is small, the same delta on a 30 s interlude is
|
||||
// huge. At lengthFullyWrongPct of candidate length the score
|
||||
// hits zero; beyond that it stays clamped to zero.
|
||||
lengthExactMs int64 = 2000
|
||||
lengthExactMs int64 = 5000
|
||||
lengthFullyWrongPct float64 = 0.20
|
||||
|
||||
// A title below titleReject has too little signal for this
|
||||
@@ -139,17 +262,31 @@ func lengthScore(localMs, candidateMs int64) float64 {
|
||||
return 1.0 - pct/lengthFullyWrongPct
|
||||
}
|
||||
|
||||
// trackDistance scores how well one local track aligns with one
|
||||
// candidate track. Higher is better. Caller decides what to do
|
||||
// with the result — this function has no threshold.
|
||||
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
|
||||
title := titleSimilarity(local.Title, cand.Title)
|
||||
length := lengthScore(local.LengthMillis, cand.LengthMillis)
|
||||
// trackNumberOK reports whether the local track number agrees with
|
||||
// the candidate position (0 = unknown, never a match).
|
||||
func trackNumberOK(local LocalTrack, cand CandidateTrack) bool {
|
||||
return local.TrackNumber > 0 && local.TrackNumber == cand.Position
|
||||
}
|
||||
|
||||
// combineTrackScore folds the per-track components into one score.
|
||||
// Split out so AlignTracks can compute the components once per pair
|
||||
// and still share the exact formula with trackDistance.
|
||||
func combineTrackScore(title, length float64, numberOK bool) float64 {
|
||||
var trackOK float64
|
||||
if local.TrackNumber > 0 && local.TrackNumber == cand.Position {
|
||||
if numberOK {
|
||||
trackOK = 1.0
|
||||
}
|
||||
|
||||
return title*weightTitle + length*weightLength + trackOK*weightTrackNumber
|
||||
}
|
||||
|
||||
// trackDistance scores how well one local track aligns with one
|
||||
// candidate track. Higher is better. Caller decides what to do
|
||||
// with the result — this function has no threshold.
|
||||
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
|
||||
return combineTrackScore(
|
||||
titleSimilarity(local.Title, cand.Title),
|
||||
lengthScore(local.LengthMillis, cand.LengthMillis),
|
||||
trackNumberOK(local, cand),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,10 +33,18 @@ func TestTitleSimilarity(t *testing.T) {
|
||||
minScore float64
|
||||
}{
|
||||
{"Hey Jude", "Hey Jude", 1.00},
|
||||
{"Hey Jude", "Hey Jude (Remastered 2009)", 1.00}, // qualifier stripped
|
||||
{"Hey Jude", "Hey Jude (Remastered 2009)", 1.00}, // whitelisted qualifier: free
|
||||
{"Hey Jude", "Hey Jude - 2015 Remaster", 1.00}, // dash-suffix qualifier: free
|
||||
{"Hey Jude", "HEY JUDE!", 1.00}, // case + punct
|
||||
{"Hey Jude", "Hay Jude", 0.85}, // one char off
|
||||
{"Hey Jude", "Let It Be", 0.00}, // different
|
||||
{"Beyoncé", "Beyonce", 1.00}, // transliteration
|
||||
{"Simon & Garfunkel", "Simon and Garfunkel", 1.00},
|
||||
{"Beatles, The", "The Beatles", 1.00}, // article rotation
|
||||
{"Hey Jude", "Hay Jude", 0.85}, // one char off
|
||||
// Unknown parenthetical content is de-weighted, not free —
|
||||
// still similar, but detectably not identical.
|
||||
{"Song Title (Special Whatever)", "Song Title", 0.80},
|
||||
{"Yellow (feat. Somebody)", "Yellow", 0.90}, // feat credit nearly free
|
||||
{"Hey Jude", "Let It Be", 0.00}, // different
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -66,9 +74,9 @@ func TestLengthScore(t *testing.T) {
|
||||
want float64
|
||||
}{
|
||||
{200000, 200000, 1.0}, // exact
|
||||
{200000, 200500, 1.0}, // 0.5s — under 2s threshold
|
||||
{200000, 201900, 1.0}, // 1.9s — under 2s threshold
|
||||
{200000, 202000, 1.0}, // exactly 2s — still full credit
|
||||
{200000, 200500, 1.0}, // 0.5s — under the grace band
|
||||
{200000, 204900, 1.0}, // 4.9s — under the grace band
|
||||
{200000, 205000, 1.0}, // exactly 5s — still full credit
|
||||
{0, 200000, 0.5}, // unknown local → neutral
|
||||
{200000, 0, 0.5}, // unknown candidate → neutral
|
||||
|
||||
@@ -130,3 +138,104 @@ func TestTrackDistance(t *testing.T) {
|
||||
t.Errorf("wrong match = %.2f, want < 0.2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDominantArtist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
local []LocalTrack
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, ""},
|
||||
{"all blank", []LocalTrack{{Artist: ""}, {Artist: ""}}, ""},
|
||||
{
|
||||
"unanimous",
|
||||
[]LocalTrack{{Artist: "Radiohead"}, {Artist: "Radiohead"}},
|
||||
"Radiohead",
|
||||
},
|
||||
{
|
||||
"majority wins over a stray",
|
||||
[]LocalTrack{{Artist: "Radiohead"}, {Artist: "Radiohead"}, {Artist: "Guest"}},
|
||||
"Radiohead",
|
||||
},
|
||||
{
|
||||
"blanks ignored, one real value wins",
|
||||
[]LocalTrack{{Artist: ""}, {Artist: "Bjork"}, {Artist: ""}},
|
||||
"Bjork",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := dominantArtist(tc.local); got != tc.want {
|
||||
t.Errorf("%s: dominantArtist = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistCreditFit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
local, cnd string
|
||||
wantMin float64 // when > 0, require >= ; when 0, require <= 0.5 (mismatch)
|
||||
}{
|
||||
{"unknown local is neutral", "", "Whoever", 1.0},
|
||||
{"unknown candidate is neutral", "Whoever", "", 1.0},
|
||||
{"exact", "The Beatles", "The Beatles", 1.0},
|
||||
{"case/punct only", "The Beatles", "THE BEATLES!", 1.0},
|
||||
{"almost-right stays high", "Beyonce", "Beyoncé", 0.85},
|
||||
{"completely different artist", "The Beatles", "Metallica", 0.0},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := artistCreditFit(tc.local, tc.cnd)
|
||||
if tc.wantMin == 0 {
|
||||
if got > 0.5 { //nolint:mnd
|
||||
t.Errorf(
|
||||
"%s: artistCreditFit(%q,%q) = %.2f, want low",
|
||||
tc.name,
|
||||
tc.local,
|
||||
tc.cnd,
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if got < tc.wantMin {
|
||||
t.Errorf(
|
||||
"%s: artistCreditFit(%q,%q) = %.2f, want >= %.2f",
|
||||
tc.name,
|
||||
tc.local,
|
||||
tc.cnd,
|
||||
got,
|
||||
tc.wantMin,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceFactor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
tracks int
|
||||
want float64
|
||||
}{
|
||||
{0, evidenceFloor}, // no tracks — treated as minimum evidence
|
||||
{1, evidenceFloor}, // singleton — the harshest case
|
||||
{2, 0.925}, // halfway between floor and full
|
||||
{3, 1.0}, // full evidence
|
||||
{10, 1.0}, // large album — unscaled
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := evidenceFactor(tc.tracks)
|
||||
if diff := got - tc.want; diff < -0.001 || diff > 0.001 {
|
||||
t.Errorf("evidenceFactor(%d) = %.4f, want %.4f", tc.tracks, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package eval is the autotag candidate-scoring evaluation harness.
|
||||
// It turns "this match feels wrong" into a number that goes up or
|
||||
// down, so a scoring change can be validated against a frozen set of
|
||||
// labelled cases instead of tuned by anecdote.
|
||||
//
|
||||
// A case describes a local album-group (the files on disk) plus a set
|
||||
// of candidate releases, and pins expectations: which candidate must
|
||||
// rank first, and per-candidate score floors/ceilings. The harness
|
||||
// is decoupled from the scorer: a caller adapts whatever ranking
|
||||
// function it wants to measure to the Ranker interface (the autotag
|
||||
// package wires autotag.RankCandidates to it in eval_harness_test.go).
|
||||
//
|
||||
// The point of the ceiling assertions is negative testing: a known
|
||||
// wrong candidate (same title, different artist) must stay BELOW a
|
||||
// confidence bar, which is exactly the false-positive class the
|
||||
// artist term + evidence scaling exist to suppress.
|
||||
package eval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ErrNoCases is returned when a fixture file contains zero cases.
|
||||
var ErrNoCases = errors.New("eval: fixture set is empty")
|
||||
|
||||
// LocalTrackFixture is one on-disk track, in the shape a case author
|
||||
// hand-writes. Millisecond durations match the scorer's units.
|
||||
type LocalTrackFixture struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Track int `json:"track,omitempty"`
|
||||
Disc int `json:"disc,omitempty"`
|
||||
LengthMs int64 `json:"lengthMs,omitempty"`
|
||||
}
|
||||
|
||||
// CandidateTrackFixture is one track inside a candidate release.
|
||||
type CandidateTrackFixture struct {
|
||||
Pos int `json:"pos"`
|
||||
Disc int `json:"disc,omitempty"`
|
||||
Title string `json:"title"`
|
||||
LengthMs int64 `json:"lengthMs,omitempty"`
|
||||
}
|
||||
|
||||
// CandidateFixture is one candidate release the scorer must rank.
|
||||
// Source is "local" or "musicbrainz" (default) — it drives
|
||||
// evidence scaling, so it matters for singleton cases.
|
||||
type CandidateFixture struct {
|
||||
MBID string `json:"mbid"`
|
||||
Title string `json:"title,omitempty"`
|
||||
ArtistCredit string `json:"artistCredit,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
PrimaryType string `json:"primaryType,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tracks []CandidateTrackFixture `json:"tracks"`
|
||||
}
|
||||
|
||||
// Case is a single labelled scoring scenario. Every real-world
|
||||
// mismatch worth guarding against belongs here so it can never
|
||||
// silently regress.
|
||||
//
|
||||
// - AlbumName / AlbumArtist mirror the tagging item's fields —
|
||||
// leave them empty to keep the album-title / artist terms
|
||||
// neutral for the case.
|
||||
// - ExpectTop, when set, is the MBID that must rank first.
|
||||
// - MaxScore pins per-MBID ceilings (candidate must score <= value).
|
||||
// - MinScore pins per-MBID floors (candidate must score >= value).
|
||||
type Case struct {
|
||||
Note string `json:"note,omitempty"`
|
||||
AlbumName string `json:"albumName,omitempty"`
|
||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||
Local []LocalTrackFixture `json:"local"`
|
||||
Candidates []CandidateFixture `json:"candidates"`
|
||||
ExpectTop string `json:"expectTop,omitempty"`
|
||||
MaxScore map[string]float64 `json:"maxScore,omitempty"`
|
||||
MinScore map[string]float64 `json:"minScore,omitempty"`
|
||||
}
|
||||
|
||||
// LoadCases reads a JSON fixture file from disk.
|
||||
func LoadCases(path string) ([]Case, error) {
|
||||
f, err := os.Open(path) //nolint:gosec // path is a test fixture, not user input
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eval: open cases: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
return ParseCases(f)
|
||||
}
|
||||
|
||||
// ParseCases decodes a JSON case set from a reader.
|
||||
func ParseCases(r io.Reader) ([]Case, error) {
|
||||
var cases []Case
|
||||
|
||||
if err := json.NewDecoder(r).Decode(&cases); err != nil {
|
||||
return nil, fmt.Errorf("eval: decode cases: %w", err)
|
||||
}
|
||||
|
||||
if len(cases) == 0 {
|
||||
return nil, ErrNoCases
|
||||
}
|
||||
|
||||
return cases, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package eval
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ScoredCandidate is one ranked candidate reduced to what the harness
|
||||
// checks: its MBID and final score. The slice a Ranker returns must
|
||||
// be ordered best-first.
|
||||
type ScoredCandidate struct {
|
||||
MBID string
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Ranker scores and orders the candidates of a single case. Best
|
||||
// candidate first. Implemented by adapting the real scorer (see
|
||||
// eval_harness_test.go), which is why the eval package itself never
|
||||
// imports autotag.
|
||||
type Ranker interface {
|
||||
Rank(c Case) []ScoredCandidate
|
||||
}
|
||||
|
||||
// RankerFunc adapts a plain function to the Ranker interface.
|
||||
type RankerFunc func(c Case) []ScoredCandidate
|
||||
|
||||
// Rank calls the underlying function.
|
||||
func (f RankerFunc) Rank(c Case) []ScoredCandidate {
|
||||
return f(c)
|
||||
}
|
||||
|
||||
// CaseResult records how one case fared. Failures is empty when the
|
||||
// case passed every pinned expectation.
|
||||
type CaseResult struct {
|
||||
Case Case
|
||||
Failures []string
|
||||
}
|
||||
|
||||
// Passed reports whether the case met every expectation.
|
||||
func (r CaseResult) Passed() bool {
|
||||
return len(r.Failures) == 0
|
||||
}
|
||||
|
||||
// Report aggregates results across a case set.
|
||||
type Report struct {
|
||||
Results []CaseResult
|
||||
}
|
||||
|
||||
// Passed counts cases that met every expectation.
|
||||
func (r Report) Passed() int {
|
||||
n := 0
|
||||
|
||||
for _, c := range r.Results {
|
||||
if c.Passed() {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// Accuracy is the fraction of cases that passed, in [0, 1].
|
||||
func (r Report) Accuracy() float64 {
|
||||
if len(r.Results) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return float64(r.Passed()) / float64(len(r.Results))
|
||||
}
|
||||
|
||||
// Evaluate runs every case through the ranker and checks its pinned
|
||||
// expectations (ExpectTop, MaxScore ceilings, MinScore floors),
|
||||
// returning a Report the caller can assert on and print.
|
||||
func Evaluate(cases []Case, ranker Ranker) Report {
|
||||
rep := Report{Results: make([]CaseResult, 0, len(cases))}
|
||||
|
||||
for _, c := range cases {
|
||||
rep.Results = append(rep.Results, evaluateCase(c, ranker))
|
||||
}
|
||||
|
||||
return rep
|
||||
}
|
||||
|
||||
func evaluateCase(c Case, ranker Ranker) CaseResult {
|
||||
ranked := ranker.Rank(c)
|
||||
|
||||
res := CaseResult{Case: c}
|
||||
|
||||
byMBID := make(map[string]float64, len(ranked))
|
||||
for _, r := range ranked {
|
||||
byMBID[r.MBID] = r.Score
|
||||
}
|
||||
|
||||
if c.ExpectTop != "" {
|
||||
switch {
|
||||
case len(ranked) == 0:
|
||||
res.Failures = append(
|
||||
res.Failures,
|
||||
"expected top "+c.ExpectTop+" but ranking was empty",
|
||||
)
|
||||
case ranked[0].MBID != c.ExpectTop:
|
||||
res.Failures = append(res.Failures, fmt.Sprintf(
|
||||
"top = %q (%.3f), want %q (%.3f)",
|
||||
ranked[0].MBID, ranked[0].Score, c.ExpectTop, byMBID[c.ExpectTop],
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
for mbid, ceil := range c.MaxScore {
|
||||
if got, ok := byMBID[mbid]; ok && got > ceil {
|
||||
res.Failures = append(res.Failures, fmt.Sprintf(
|
||||
"%s scored %.3f, want <= %.3f", mbid, got, ceil,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
for mbid, floor := range c.MinScore {
|
||||
if got, ok := byMBID[mbid]; ok && got < floor {
|
||||
res.Failures = append(res.Failures, fmt.Sprintf(
|
||||
"%s scored %.3f, want >= %.3f", mbid, got, floor,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
[
|
||||
{
|
||||
"note": "seed: single with a generic title must reject a same-title, different-artist, wrong-length MB hit and prefer the correct artist. This is the reported 92%-false-positive class.",
|
||||
"local": [
|
||||
{ "title": "Intro", "artist": "Real Artist", "track": 1, "lengthMs": 90000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "right",
|
||||
"artistCredit": "Real Artist",
|
||||
"status": "Official",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [{ "pos": 1, "title": "Intro", "lengthMs": 90000 }]
|
||||
},
|
||||
{
|
||||
"mbid": "wrong-artist",
|
||||
"artistCredit": "Some Other Band",
|
||||
"status": "Official",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [{ "pos": 1, "title": "Intro", "lengthMs": 240000 }]
|
||||
}
|
||||
],
|
||||
"expectTop": "right",
|
||||
"maxScore": { "wrong-artist": 0.75 }
|
||||
},
|
||||
{
|
||||
"note": "seed: full-album exact match should still read as a confident, near-perfect match (evidence scaling must not punish real albums).",
|
||||
"local": [
|
||||
{ "title": "Song A", "artist": "The Band", "track": 1, "lengthMs": 200000 },
|
||||
{ "title": "Song B", "artist": "The Band", "track": 2, "lengthMs": 210000 },
|
||||
{ "title": "Song C", "artist": "The Band", "track": 3, "lengthMs": 195000 },
|
||||
{ "title": "Song D", "artist": "The Band", "track": 4, "lengthMs": 220000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "album",
|
||||
"artistCredit": "The Band",
|
||||
"status": "Official",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
|
||||
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
|
||||
{ "pos": 3, "title": "Song C", "lengthMs": 195000 },
|
||||
{ "pos": 4, "title": "Song D", "lengthMs": 220000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"expectTop": "album",
|
||||
"minScore": { "album": 0.9 }
|
||||
},
|
||||
{
|
||||
"note": "seed: near-right artist (accent/spelling) must stay a strong match; soft artist term, not a gate.",
|
||||
"local": [
|
||||
{ "title": "Jolene", "artist": "Beyonce", "track": 1, "lengthMs": 200000 },
|
||||
{ "title": "Halo", "artist": "Beyonce", "track": 2, "lengthMs": 220000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "accented",
|
||||
"artistCredit": "Beyoncé",
|
||||
"status": "Official",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "title": "Jolene", "lengthMs": 200000 },
|
||||
{ "pos": 2, "title": "Halo", "lengthMs": 220000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"expectTop": "accented",
|
||||
"minScore": { "accented": 0.85 }
|
||||
},
|
||||
{
|
||||
"note": "VA compilation: per-track artists all differ, album-artist says Various Artists. The VA candidate must not be penalised for its artist credit and must read as a strong match.",
|
||||
"albumName": "Now That's What I Call Music! 60",
|
||||
"albumArtist": "Various Artists",
|
||||
"local": [
|
||||
{ "title": "Song One", "artist": "Artist A", "track": 1, "lengthMs": 200000 },
|
||||
{ "title": "Song Two", "artist": "Artist B", "track": 2, "lengthMs": 210000 },
|
||||
{ "title": "Song Three", "artist": "Artist C", "track": 3, "lengthMs": 195000 },
|
||||
{ "title": "Song Four", "artist": "Artist D", "track": 4, "lengthMs": 205000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "va-comp",
|
||||
"title": "Now That's What I Call Music! 60",
|
||||
"artistCredit": "Various Artists",
|
||||
"status": "Official",
|
||||
"primaryType": "Compilation",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "title": "Song One", "lengthMs": 200000 },
|
||||
{ "pos": 2, "title": "Song Two", "lengthMs": 210000 },
|
||||
{ "pos": 3, "title": "Song Three", "lengthMs": 195000 },
|
||||
{ "pos": 4, "title": "Song Four", "lengthMs": 205000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"expectTop": "va-comp",
|
||||
"minScore": { "va-comp": 0.85 }
|
||||
},
|
||||
{
|
||||
"note": "same recordings, two release groups: folder album name must pull the studio album above the greatest-hits comp with an identical tracklist.",
|
||||
"albumName": "The Studio Album",
|
||||
"albumArtist": "The Band",
|
||||
"local": [
|
||||
{ "title": "Song A", "artist": "The Band", "track": 1, "lengthMs": 200000 },
|
||||
{ "title": "Song B", "artist": "The Band", "track": 2, "lengthMs": 210000 },
|
||||
{ "title": "Song C", "artist": "The Band", "track": 3, "lengthMs": 195000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "studio",
|
||||
"title": "The Studio Album",
|
||||
"artistCredit": "The Band",
|
||||
"status": "Official",
|
||||
"primaryType": "Album",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
|
||||
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
|
||||
{ "pos": 3, "title": "Song C", "lengthMs": 195000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"mbid": "hits-comp",
|
||||
"title": "Greatest Hits",
|
||||
"artistCredit": "The Band",
|
||||
"status": "Official",
|
||||
"primaryType": "Compilation",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
|
||||
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
|
||||
{ "pos": 3, "title": "Song C", "lengthMs": 195000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"expectTop": "studio",
|
||||
"minScore": { "studio": 0.9 }
|
||||
},
|
||||
{
|
||||
"note": "disc 1 of a 2-disc release: per-disc group must not be punished for 'missing' disc 2, and must not lose to a random single-disc release with fewer matching tracks.",
|
||||
"albumName": "The Double Album",
|
||||
"albumArtist": "The Band",
|
||||
"local": [
|
||||
{ "title": "D1 Track 1", "artist": "The Band", "track": 1, "disc": 1, "lengthMs": 200000 },
|
||||
{ "title": "D1 Track 2", "artist": "The Band", "track": 2, "disc": 1, "lengthMs": 210000 },
|
||||
{ "title": "D1 Track 3", "artist": "The Band", "track": 3, "disc": 1, "lengthMs": 195000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "double",
|
||||
"title": "The Double Album",
|
||||
"artistCredit": "The Band",
|
||||
"status": "Official",
|
||||
"primaryType": "Album",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "disc": 1, "title": "D1 Track 1", "lengthMs": 200000 },
|
||||
{ "pos": 2, "disc": 1, "title": "D1 Track 2", "lengthMs": 210000 },
|
||||
{ "pos": 3, "disc": 1, "title": "D1 Track 3", "lengthMs": 195000 },
|
||||
{ "pos": 1, "disc": 2, "title": "D2 Track 1", "lengthMs": 220000 },
|
||||
{ "pos": 2, "disc": 2, "title": "D2 Track 2", "lengthMs": 230000 },
|
||||
{ "pos": 3, "disc": 2, "title": "D2 Track 3", "lengthMs": 240000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"expectTop": "double",
|
||||
"minScore": { "double": 0.9 }
|
||||
},
|
||||
{
|
||||
"note": "streaming-style dash qualifiers: '- 2011 Remaster' suffixes on every local title must not cost title score against the clean MB tracklist.",
|
||||
"albumName": "Classic Album",
|
||||
"albumArtist": "The Band",
|
||||
"local": [
|
||||
{ "title": "Opener - 2011 Remaster", "artist": "The Band", "track": 1, "lengthMs": 200000 },
|
||||
{ "title": "Middle Cut - 2011 Remaster", "artist": "The Band", "track": 2, "lengthMs": 210000 },
|
||||
{ "title": "Closer - 2011 Remaster", "artist": "The Band", "track": 3, "lengthMs": 195000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "clean-titles",
|
||||
"title": "Classic Album",
|
||||
"artistCredit": "The Band",
|
||||
"status": "Official",
|
||||
"primaryType": "Album",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "title": "Opener", "lengthMs": 200000 },
|
||||
{ "pos": 2, "title": "Middle Cut", "lengthMs": 210000 },
|
||||
{ "pos": 3, "title": "Closer", "lengthMs": 195000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"expectTop": "clean-titles",
|
||||
"minScore": { "clean-titles": 0.9 }
|
||||
},
|
||||
{
|
||||
"note": "sorted-artist tag: 'Beatles, The' must match 'The Beatles' releases at full strength (article rotation).",
|
||||
"albumName": "Abbey Road",
|
||||
"albumArtist": "Beatles, The",
|
||||
"local": [
|
||||
{ "title": "Come Together", "artist": "Beatles, The", "track": 1, "lengthMs": 259000 },
|
||||
{ "title": "Something", "artist": "Beatles, The", "track": 2, "lengthMs": 182000 },
|
||||
{ "title": "Octopus's Garden", "artist": "Beatles, The", "track": 3, "lengthMs": 170000 }
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"mbid": "abbey",
|
||||
"title": "Abbey Road",
|
||||
"artistCredit": "The Beatles",
|
||||
"status": "Official",
|
||||
"primaryType": "Album",
|
||||
"source": "musicbrainz",
|
||||
"tracks": [
|
||||
{ "pos": 1, "title": "Come Together", "lengthMs": 259000 },
|
||||
{ "pos": 2, "title": "Something", "lengthMs": 182000 },
|
||||
{ "pos": 3, "title": "Octopus's Garden", "lengthMs": 170000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"expectTop": "abbey",
|
||||
"minScore": { "abbey": 0.9 }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
package autotag_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/autotag/eval"
|
||||
)
|
||||
|
||||
// adaptRanker converts an eval.Case (hand-written fixture shapes) into
|
||||
// the autotag domain types, runs the real RankCandidates, and maps
|
||||
// the result back to []eval.ScoredCandidate. This is the one place
|
||||
// the eval package's decoupled fixtures meet the concrete scorer.
|
||||
func adaptRanker(c eval.Case) []eval.ScoredCandidate {
|
||||
locals := make([]autotag.LocalTrack, len(c.Local))
|
||||
for i, l := range c.Local {
|
||||
locals[i] = autotag.LocalTrack{
|
||||
Title: l.Title,
|
||||
Artist: l.Artist,
|
||||
TrackNumber: l.Track,
|
||||
DiscNumber: l.Disc,
|
||||
LengthMillis: l.LengthMs,
|
||||
}
|
||||
}
|
||||
|
||||
cands := make([]autotag.Candidate, len(c.Candidates))
|
||||
for i, cf := range c.Candidates {
|
||||
tracks := make([]autotag.CandidateTrack, len(cf.Tracks))
|
||||
for j, tf := range cf.Tracks {
|
||||
tracks[j] = autotag.CandidateTrack{
|
||||
Position: tf.Pos,
|
||||
DiscNumber: tf.Disc,
|
||||
Title: tf.Title,
|
||||
LengthMillis: tf.LengthMs,
|
||||
}
|
||||
}
|
||||
|
||||
cands[i] = autotag.Candidate{
|
||||
ReleaseMBID: cf.MBID,
|
||||
Title: cf.Title,
|
||||
ArtistCredit: cf.ArtistCredit,
|
||||
Status: cf.Status,
|
||||
Country: cf.Country,
|
||||
PrimaryType: cf.PrimaryType,
|
||||
Source: candidateSource(cf.Source),
|
||||
Tracks: tracks,
|
||||
}
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(autotag.Group{
|
||||
AlbumName: c.AlbumName,
|
||||
AlbumArtist: c.AlbumArtist,
|
||||
Tracks: locals,
|
||||
}, cands)
|
||||
|
||||
out := make([]eval.ScoredCandidate, len(ranked))
|
||||
for i, r := range ranked {
|
||||
out[i] = eval.ScoredCandidate{MBID: r.ReleaseMBID, Score: r.Score}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// candidateSource maps the fixture string to the domain type,
|
||||
// defaulting to MusicBrainz (the interesting, evidence-scaled path).
|
||||
func candidateSource(s string) autotag.CandidateSource {
|
||||
if s == string(autotag.SourceLocal) {
|
||||
return autotag.SourceLocal
|
||||
}
|
||||
|
||||
return autotag.SourceMusicBrainz
|
||||
}
|
||||
|
||||
// TestScoringCorpus runs the frozen labelled corpus through the real
|
||||
// ranker. Add real-world mismatches to testdata/scoring_cases.json —
|
||||
// every case that fails here is a scoring regression, and the harness
|
||||
// prints exactly which expectation broke.
|
||||
func TestScoringCorpus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases, err := eval.LoadCases("eval/testdata/scoring_cases.json")
|
||||
if err != nil {
|
||||
t.Fatalf("load cases: %v", err)
|
||||
}
|
||||
|
||||
report := eval.Evaluate(cases, eval.RankerFunc(adaptRanker))
|
||||
|
||||
for _, r := range report.Results {
|
||||
if r.Passed() {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range r.Failures {
|
||||
t.Errorf("case %q: %s", r.Case.Note, f)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf(
|
||||
"scoring corpus: %d/%d cases passed (%.0f%% accuracy)",
|
||||
report.Passed(), len(report.Results), report.Accuracy()*100,
|
||||
)
|
||||
}
|
||||
+327
-51
@@ -32,6 +32,25 @@ type MBRelease struct {
|
||||
Tracks []CandidateTrack
|
||||
}
|
||||
|
||||
// MBRecordingHit is the minimal projection of a MusicBrainz recording
|
||||
// search result — used by the in-app search path (singletons).
|
||||
type MBRecordingHit struct {
|
||||
MBID string
|
||||
Title string
|
||||
ArtistCredit string
|
||||
LengthMillis int64
|
||||
}
|
||||
|
||||
// MBReleaseRef is a slim reference to one release a recording appears
|
||||
// on. The resolver ranks these to pick a representative release and
|
||||
// then resolves it in full via ResolveOneReleaseMBID.
|
||||
type MBReleaseRef struct {
|
||||
MBID string
|
||||
Title string
|
||||
Status string
|
||||
Date string
|
||||
}
|
||||
|
||||
// MBClient is the subset of the explore.MusicBrainzClient surface
|
||||
// the autotagger depends on. Implementations must be cache-first
|
||||
// — repeated calls with the same inputs must not repeat network
|
||||
@@ -42,16 +61,42 @@ type MBClient interface {
|
||||
query string,
|
||||
limit int,
|
||||
) ([]MBReleaseGroupHit, int, error)
|
||||
SearchRecordings(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
limit int,
|
||||
) ([]MBRecordingHit, int, error)
|
||||
LookupRecordingReleases(ctx context.Context, recordingMBID string) ([]MBReleaseRef, error)
|
||||
BrowseReleases(ctx context.Context, releaseGroupMBID string) ([]MBRelease, error)
|
||||
LookupRelease(ctx context.Context, releaseMBID string) (MBRelease, error)
|
||||
LookupReleaseGroup(ctx context.Context, releaseGroupMBID string) (MBReleaseGroupHit, error)
|
||||
LookupArtist(ctx context.Context, mbid string) (string, error) // returns sort name or name
|
||||
}
|
||||
|
||||
// mbidVariousArtists is the MusicBrainz artist MBID for the special
|
||||
// "Various Artists" entity — used as an arid filter when a group
|
||||
// looks like a compilation.
|
||||
const mbidVariousArtists = "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
|
||||
// cascadeSufficient is the merged-best score at which the cascade
|
||||
// stops issuing looser queries. "First step with any hits" is the
|
||||
// wrong stop condition — a strict query can return plausible-but-
|
||||
// wrong release groups and starve the looser steps of the chance to
|
||||
// surface the right one. Scoring is free; searches and browses are
|
||||
// rate-limited network calls, so the cascade pays for another step
|
||||
// only while the best candidate so far is still mediocre.
|
||||
const cascadeSufficient = 0.70
|
||||
|
||||
// hitBrowseFloor is the minimum title-or-artist similarity a search
|
||||
// hit needs before the resolver pays a rate-limited BrowseReleases
|
||||
// call for it. Hits failing both checks are junk from MB's fuzzy
|
||||
// tokenizer.
|
||||
const hitBrowseFloor = 0.30
|
||||
|
||||
// MBResolver orchestrates MusicBrainz lookups for a tagging group.
|
||||
// Strategy: normalize the user-provided album/artist first, then
|
||||
// issue a cascade of progressively looser Lucene queries, stopping
|
||||
// at the first one that yields enough candidates.
|
||||
// Strategy: use recording MBIDs already present in local tags when
|
||||
// possible (exact, cheap); otherwise issue a cascade of
|
||||
// progressively looser Lucene queries, merging results until a
|
||||
// candidate scores well enough to stop.
|
||||
type MBResolver struct {
|
||||
client MBClient
|
||||
logger *slog.Logger
|
||||
@@ -73,27 +118,21 @@ type mbQueryStep struct {
|
||||
}
|
||||
|
||||
// ResolveMB returns MB-sourced candidates for a tagging group.
|
||||
// Runs a cascade of Lucene queries, returning at the first step
|
||||
// that produces results. Each search hit fans out to
|
||||
// BrowseReleases (one per release-group) for track-level data.
|
||||
func (r *MBResolver) ResolveMB(
|
||||
ctx context.Context,
|
||||
albumName, albumArtist string,
|
||||
trackCount int,
|
||||
knownArtistMBID string,
|
||||
) ([]Candidate, error) {
|
||||
if albumName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
nAlbum := Normalize(albumName)
|
||||
nArtist := Normalize(albumArtist)
|
||||
|
||||
// Runs the Lucene query cascade, accumulating deduplicated
|
||||
// candidates across steps and stopping once the best merged
|
||||
// candidate scores at least cascadeSufficient against the group.
|
||||
func (r *MBResolver) ResolveMB(ctx context.Context, g Group) ([]Candidate, error) {
|
||||
nAlbum := Normalize(g.AlbumName)
|
||||
if nAlbum == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
steps := buildMBQueryCascade(nAlbum, nArtist, trackCount, knownArtistMBID)
|
||||
nArtist := Normalize(groupArtist(g))
|
||||
steps := buildMBQueryCascade(nAlbum, nArtist, len(g.Tracks), vaLikely(g))
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var merged []Candidate
|
||||
|
||||
for _, step := range steps {
|
||||
hits, _, err := r.client.SearchReleaseGroups(ctx, step.query, r.limit)
|
||||
@@ -106,35 +145,65 @@ func (r *MBResolver) ResolveMB(
|
||||
continue
|
||||
}
|
||||
|
||||
if len(hits) == 0 {
|
||||
added := r.fanOutBrowse(ctx, g, hits, step.label, seen, &merged)
|
||||
|
||||
r.logger.Debug(
|
||||
"MB search step done",
|
||||
"step", step.label, "hits", len(hits), "new_candidates", added,
|
||||
)
|
||||
|
||||
if added == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Score what we have so far; good enough means the looser
|
||||
// (noisier, costlier) steps aren't needed.
|
||||
ranked := RankCandidates(g, merged)
|
||||
if len(ranked) > 0 && ranked[0].Score >= cascadeSufficient {
|
||||
r.logger.Info(
|
||||
"MB cascade stopped — sufficient candidate",
|
||||
"step", step.label, "score", ranked[0].Score,
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// fanOutBrowse iterates search hits, fetches each plausible
|
||||
// release-group's releases, and appends previously-unseen ones to
|
||||
// merged as Candidates. Returns how many candidates were added.
|
||||
// Errors on individual browses are logged and skipped.
|
||||
func (r *MBResolver) fanOutBrowse(
|
||||
ctx context.Context,
|
||||
g Group,
|
||||
hits []MBReleaseGroupHit,
|
||||
step string,
|
||||
seen map[string]bool,
|
||||
merged *[]Candidate,
|
||||
) int {
|
||||
added := 0
|
||||
|
||||
for _, h := range hits {
|
||||
if seen["rg:"+h.MBID] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen["rg:"+h.MBID] = true
|
||||
|
||||
// Don't pay a rate-limited browse for a hit that resembles
|
||||
// neither the folder's album name nor its artist.
|
||||
if !hitPlausible(g, h) {
|
||||
r.logger.Debug(
|
||||
"MB search step empty — trying next",
|
||||
"step", step.label, "query", step.query,
|
||||
"skipping implausible search hit",
|
||||
"title", h.Title, "artist", h.ArtistCredit,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
r.logger.Info(
|
||||
"MB search step succeeded",
|
||||
"step", step.label, "hits", len(hits),
|
||||
)
|
||||
|
||||
return r.fanOutBrowse(ctx, hits, step.label), nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// fanOutBrowse iterates search hits, fetches each release-group's
|
||||
// releases, and returns them as Candidates. Errors on individual
|
||||
// browses are logged and skipped.
|
||||
func (r *MBResolver) fanOutBrowse(
|
||||
ctx context.Context, hits []MBReleaseGroupHit, step string,
|
||||
) []Candidate {
|
||||
var out []Candidate
|
||||
|
||||
for _, h := range hits {
|
||||
releases, err := r.client.BrowseReleases(ctx, h.MBID)
|
||||
if err != nil {
|
||||
r.logger.Warn(
|
||||
@@ -146,11 +215,105 @@ func (r *MBResolver) fanOutBrowse(
|
||||
}
|
||||
|
||||
for _, rel := range releases {
|
||||
out = append(out, mkCandidate(h, rel, step))
|
||||
if rel.MBID != "" && seen[rel.MBID] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[rel.MBID] = true
|
||||
|
||||
*merged = append(*merged, mkCandidate(h, rel, step))
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
return added
|
||||
}
|
||||
|
||||
// hitPlausible reports whether a release-group search hit is worth
|
||||
// a BrowseReleases round-trip: its title or artist must bear at
|
||||
// least a loose resemblance to the group's. Unknown local fields
|
||||
// never disqualify a hit.
|
||||
func hitPlausible(g Group, h MBReleaseGroupHit) bool {
|
||||
if g.AlbumName != "" && h.Title != "" &&
|
||||
titleSimilarity(g.AlbumName, h.Title) >= hitBrowseFloor {
|
||||
return true
|
||||
}
|
||||
|
||||
artist := groupArtist(g)
|
||||
if artist != "" && h.ArtistCredit != "" &&
|
||||
titleSimilarity(artist, h.ArtistCredit) >= hitBrowseFloor {
|
||||
return true
|
||||
}
|
||||
|
||||
// Nothing to compare against (or a VA credit): stay permissive.
|
||||
return g.AlbumName == "" || h.Title == "" || isVAName(h.ArtistCredit)
|
||||
}
|
||||
|
||||
// ResolveByRecordingMBIDs resolves candidates from recording MBIDs
|
||||
// already present in the local tags — the highest-precision signal
|
||||
// available, and the reason previously-tagged files should never
|
||||
// need a fuzzy search. Each recording is looked up, releases are
|
||||
// counted as votes, and the best-voted release (Official and
|
||||
// earliest among ties) is resolved in full with provenance "id".
|
||||
// Returns nil when no recording resolves to any release.
|
||||
func (r *MBResolver) ResolveByRecordingMBIDs(
|
||||
ctx context.Context, recordingMBIDs []string,
|
||||
) ([]Candidate, error) {
|
||||
votes := make(map[string]int)
|
||||
refs := make(map[string]MBReleaseRef)
|
||||
|
||||
for _, id := range recordingMBIDs {
|
||||
rels, err := r.client.LookupRecordingReleases(ctx, id)
|
||||
if err != nil {
|
||||
r.logger.Warn(
|
||||
"recording lookup failed — skipping",
|
||||
"recording_mbid", id, "err", err,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
counted := make(map[string]bool, len(rels))
|
||||
|
||||
for _, ref := range rels {
|
||||
if ref.MBID == "" || counted[ref.MBID] {
|
||||
continue
|
||||
}
|
||||
|
||||
counted[ref.MBID] = true
|
||||
votes[ref.MBID]++
|
||||
refs[ref.MBID] = ref
|
||||
}
|
||||
}
|
||||
|
||||
if len(votes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Highest vote count wins; betterRelease breaks ties so the
|
||||
// pick is deterministic and favours Official + earliest.
|
||||
var (
|
||||
bestMBID string
|
||||
bestVotes int
|
||||
)
|
||||
|
||||
for mbid, n := range votes {
|
||||
switch {
|
||||
case n > bestVotes:
|
||||
bestMBID, bestVotes = mbid, n
|
||||
case n == bestVotes && betterRelease(refs[mbid], refs[bestMBID]):
|
||||
bestMBID = mbid
|
||||
}
|
||||
}
|
||||
|
||||
cand, err := r.ResolveOneReleaseMBID(ctx, bestMBID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve voted release %s: %w", bestMBID, err)
|
||||
}
|
||||
|
||||
cand.Provenance = "id"
|
||||
|
||||
return []Candidate{cand}, nil
|
||||
}
|
||||
|
||||
// ResolveOneReleaseMBID fetches a single release by MBID and
|
||||
@@ -208,6 +371,7 @@ func mkCandidate(h MBReleaseGroupHit, rel MBRelease, step string) Candidate {
|
||||
OriginalDate: h.FirstDate,
|
||||
Country: rel.Country,
|
||||
Status: rel.Status,
|
||||
PrimaryType: h.PrimaryType,
|
||||
TrackCount: len(rel.Tracks),
|
||||
Tracks: rel.Tracks,
|
||||
Source: SourceMusicBrainz,
|
||||
@@ -215,19 +379,131 @@ func mkCandidate(h MBReleaseGroupHit, rel MBRelease, step string) Candidate {
|
||||
}
|
||||
}
|
||||
|
||||
// SearchReleaseGroupHits runs a single release-group search from a
|
||||
// user-supplied album + artist (the in-app "suggest a candidate"
|
||||
// path). Both fields are normalized and phrase-quoted; artist is
|
||||
// dropped from the query when empty.
|
||||
func (r *MBResolver) SearchReleaseGroupHits(
|
||||
ctx context.Context, album, artist string,
|
||||
) ([]MBReleaseGroupHit, error) {
|
||||
query := "release:" + luceneQuote(Normalize(album))
|
||||
if a := Normalize(artist); a != "" {
|
||||
query += " AND artist:" + luceneQuote(a)
|
||||
}
|
||||
|
||||
hits, _, err := r.client.SearchReleaseGroups(ctx, query, r.limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search release groups: %w", err)
|
||||
}
|
||||
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// SearchRecordingHits runs a single recording search from a user-
|
||||
// supplied title + artist — the singleton path, where the folder has
|
||||
// one track and release-group search is too coarse.
|
||||
func (r *MBResolver) SearchRecordingHits(
|
||||
ctx context.Context, title, artist string,
|
||||
) ([]MBRecordingHit, error) {
|
||||
query := "recording:" + luceneQuote(Normalize(title))
|
||||
if a := Normalize(artist); a != "" {
|
||||
query += " AND artist:" + luceneQuote(a)
|
||||
}
|
||||
|
||||
hits, _, err := r.client.SearchRecordings(ctx, query, r.limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search recordings: %w", err)
|
||||
}
|
||||
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// ResolveOneRecordingMBID turns a picked recording into a fully-scored
|
||||
// Candidate by resolving it to a representative release (so the
|
||||
// existing release-based diff + Apply pipeline works unchanged).
|
||||
// Picks the release the same way a human would default: prefer an
|
||||
// Official status, then the earliest date. Provenance is
|
||||
// "search-recording" so the UI can label where it came from.
|
||||
func (r *MBResolver) ResolveOneRecordingMBID(
|
||||
ctx context.Context, recordingMBID string,
|
||||
) (Candidate, error) {
|
||||
refs, err := r.client.LookupRecordingReleases(ctx, recordingMBID)
|
||||
if err != nil {
|
||||
return Candidate{}, fmt.Errorf("lookup recording releases: %w", err)
|
||||
}
|
||||
|
||||
best := pickRepresentativeRelease(refs)
|
||||
if best.MBID == "" {
|
||||
return Candidate{}, fmt.Errorf("%w: %s", errNoReleasesForRecording, recordingMBID)
|
||||
}
|
||||
|
||||
cand, err := r.ResolveOneReleaseMBID(ctx, best.MBID)
|
||||
if err != nil {
|
||||
return Candidate{}, err
|
||||
}
|
||||
|
||||
cand.Provenance = "search-recording"
|
||||
|
||||
return cand, nil
|
||||
}
|
||||
|
||||
// pickRepresentativeRelease chooses the release most likely to be the
|
||||
// one the user means: an Official release beats a non-Official one,
|
||||
// and among equals the earliest date wins (favouring the original
|
||||
// over later reissues). Returns the zero value for an empty slice.
|
||||
func pickRepresentativeRelease(refs []MBReleaseRef) MBReleaseRef {
|
||||
var best MBReleaseRef
|
||||
|
||||
for _, ref := range refs {
|
||||
if best.MBID == "" || betterRelease(ref, best) {
|
||||
best = ref
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// betterRelease reports whether a should be preferred over b.
|
||||
func betterRelease(a, b MBReleaseRef) bool {
|
||||
aOfficial := strings.EqualFold(a.Status, "Official")
|
||||
bOfficial := strings.EqualFold(b.Status, "Official")
|
||||
|
||||
if aOfficial != bOfficial {
|
||||
return aOfficial
|
||||
}
|
||||
|
||||
// Same official-ness: earlier date wins. Empty dates sort last
|
||||
// so a dated release beats an undated one.
|
||||
switch {
|
||||
case a.Date == "":
|
||||
return false
|
||||
case b.Date == "":
|
||||
return true
|
||||
default:
|
||||
return a.Date < b.Date
|
||||
}
|
||||
}
|
||||
|
||||
// errNoReleasesForRecording signals a recording that resolved to zero
|
||||
// releases — nothing to diff or apply against.
|
||||
var errNoReleasesForRecording = errors.New("autotag: recording has no releases")
|
||||
|
||||
// buildMBQueryCascade returns the Lucene queries to try in order.
|
||||
// Cascade:
|
||||
//
|
||||
// 1. Full: release + arid/artist + tracks:N
|
||||
// 1. Full: release + artist (or VA arid) + tracks:N
|
||||
// 2. Drop tracks:N (bonus tracks, live editions, etc.)
|
||||
// 3. Drop artist entirely (wrong artist tag is common)
|
||||
// 4. Fuzzy title (unquoted; Lucene does token/prefix match)
|
||||
//
|
||||
// Each step is only added when it would differ from the previous.
|
||||
// VA-likely groups filter on the Various Artists arid instead of an
|
||||
// artist name — per-track artists on a compilation say nothing
|
||||
// about the release's artist credit. Each step is only added when
|
||||
// it would differ from the previous.
|
||||
func buildMBQueryCascade(
|
||||
normAlbum, normArtist string,
|
||||
trackCount int,
|
||||
artistMBID string,
|
||||
va bool,
|
||||
) []mbQueryStep {
|
||||
var steps []mbQueryStep
|
||||
|
||||
@@ -235,8 +511,8 @@ func buildMBQueryCascade(
|
||||
artistClause := ""
|
||||
|
||||
switch {
|
||||
case artistMBID != "":
|
||||
artistClause = "arid:" + artistMBID
|
||||
case va:
|
||||
artistClause = "arid:" + mbidVariousArtists
|
||||
case normArtist != "":
|
||||
artistClause = "artist:" + luceneQuote(normArtist)
|
||||
}
|
||||
|
||||
+308
-18
@@ -15,11 +15,14 @@ var errFakeNotFound = errors.New("fake: not found")
|
||||
// cascade tests orchestrate `searchByStep` so step N returns hits
|
||||
// only when the resolver has already tried steps < N.
|
||||
type fakeMBClient struct {
|
||||
queries []string
|
||||
searchByStep map[int][]MBReleaseGroupHit
|
||||
browseByMBID map[string][]MBRelease
|
||||
lookupRels map[string]MBRelease
|
||||
lookupRGs map[string]MBReleaseGroupHit
|
||||
queries []string
|
||||
searchByStep map[int][]MBReleaseGroupHit
|
||||
browseByMBID map[string][]MBRelease
|
||||
browseCalls map[string]int
|
||||
lookupRels map[string]MBRelease
|
||||
lookupRGs map[string]MBReleaseGroupHit
|
||||
searchRecs []MBRecordingHit
|
||||
recRelsByMBID map[string][]MBReleaseRef
|
||||
}
|
||||
|
||||
func (f *fakeMBClient) SearchReleaseGroups(
|
||||
@@ -36,6 +39,12 @@ func (f *fakeMBClient) SearchReleaseGroups(
|
||||
func (f *fakeMBClient) BrowseReleases(
|
||||
_ context.Context, mbid string,
|
||||
) ([]MBRelease, error) {
|
||||
if f.browseCalls == nil {
|
||||
f.browseCalls = make(map[string]int)
|
||||
}
|
||||
|
||||
f.browseCalls[mbid]++
|
||||
|
||||
return f.browseByMBID[mbid], nil
|
||||
}
|
||||
|
||||
@@ -61,8 +70,18 @@ func (f *fakeMBClient) LookupReleaseGroup(
|
||||
return rg, nil
|
||||
}
|
||||
|
||||
func (f *fakeMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
|
||||
return "", nil
|
||||
func (f *fakeMBClient) SearchRecordings(
|
||||
_ context.Context, query string, _ int,
|
||||
) ([]MBRecordingHit, int, error) {
|
||||
f.queries = append(f.queries, query)
|
||||
|
||||
return f.searchRecs, len(f.searchRecs), nil
|
||||
}
|
||||
|
||||
func (f *fakeMBClient) LookupRecordingReleases(
|
||||
_ context.Context, mbid string,
|
||||
) ([]MBReleaseRef, error) {
|
||||
return f.recRelsByMBID[mbid], nil
|
||||
}
|
||||
|
||||
func TestBuildMBQueryCascade_StepsOrder(t *testing.T) {
|
||||
@@ -70,7 +89,7 @@ func TestBuildMBQueryCascade_StepsOrder(t *testing.T) {
|
||||
|
||||
// Normalize() is a no-op for these inputs — ASCII, no
|
||||
// qualifier suffix, no punctuation — so cascade builds directly.
|
||||
steps := buildMBQueryCascade("abbey road", "the beatles", 17, "")
|
||||
steps := buildMBQueryCascade("abbey road", "the beatles", 17, false)
|
||||
|
||||
if len(steps) < 3 {
|
||||
t.Fatalf("expected ≥3 cascade steps, got %d", len(steps))
|
||||
@@ -94,7 +113,7 @@ func TestBuildMBQueryCascade_NormalizesInputs(t *testing.T) {
|
||||
|
||||
// Qualifier suffix "(Remastered 2009)" must be stripped by
|
||||
// the *caller*; verify the query emitter doesn't reintroduce it.
|
||||
steps := buildMBQueryCascade("abbey road", "", 0, "")
|
||||
steps := buildMBQueryCascade("abbey road", "", 0, false)
|
||||
for _, step := range steps {
|
||||
if strings.Contains(strings.ToLower(step.query), "remastered") {
|
||||
t.Errorf("step %q should not contain qualifier: %q", step.label, step.query)
|
||||
@@ -102,26 +121,56 @@ func TestBuildMBQueryCascade_NormalizesInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_CascadeStopsOnFirstHit(t *testing.T) {
|
||||
func TestBuildMBQueryCascade_VariousArtists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// VA-likely groups must filter on the Various Artists arid, not
|
||||
// on whatever plurality artist the compilation's tracks have.
|
||||
steps := buildMBQueryCascade("now that's music", "artist one", 12, true)
|
||||
|
||||
if !strings.Contains(steps[0].query, "arid:"+mbidVariousArtists) {
|
||||
t.Errorf("VA step 1 should carry the VA arid, got %q", steps[0].query)
|
||||
}
|
||||
|
||||
if strings.Contains(steps[0].query, "artist:") {
|
||||
t.Errorf("VA step 1 must not carry an artist: clause, got %q", steps[0].query)
|
||||
}
|
||||
}
|
||||
|
||||
// abbeyRoadGroup is a group whose single track matches the rg1
|
||||
// fixture release well enough to clear cascadeSufficient.
|
||||
func abbeyRoadGroup() Group {
|
||||
return Group{
|
||||
AlbumName: "Abbey Road",
|
||||
AlbumArtist: "The Beatles",
|
||||
Tracks: []LocalTrack{{
|
||||
Title: "Come Together", TrackNumber: 1, LengthMillis: 259000,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_CascadeStopsWhenSufficient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeMBClient{
|
||||
searchByStep: map[int][]MBReleaseGroupHit{
|
||||
// step 0 (strict) returns nothing; step 1 (no-track-count) hits.
|
||||
// step 0 (strict) returns nothing; step 1 (no-track-count)
|
||||
// hits with a release that scores well against the group.
|
||||
1: {{MBID: "rg1", Title: "Abbey Road"}},
|
||||
},
|
||||
browseByMBID: map[string][]MBRelease{
|
||||
"rg1": {{MBID: "rel1", Title: "Abbey Road", Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Come Together"},
|
||||
}}},
|
||||
"rg1": {{
|
||||
MBID: "rel1", Title: "Abbey Road", Status: "Official",
|
||||
Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Come Together", LengthMillis: 259000},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveMB(
|
||||
context.Background(), "Abbey Road", "The Beatles", 17, "",
|
||||
)
|
||||
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMB: %v", err)
|
||||
}
|
||||
@@ -139,13 +188,123 @@ func TestMBResolver_CascadeStopsOnFirstHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_CascadeContinuesPastMediocreHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Step 0 returns a same-title release whose track list doesn't
|
||||
// match the folder at all — plausible enough to browse, but it
|
||||
// must NOT stop the cascade ("first non-empty step wins" was the
|
||||
// old, wrong behavior). Step 1 surfaces the real album; both
|
||||
// candidates come back merged.
|
||||
fake := &fakeMBClient{
|
||||
searchByStep: map[int][]MBReleaseGroupHit{
|
||||
0: {{MBID: "rg-decoy", Title: "Abbey Road"}},
|
||||
1: {{MBID: "rg-real", Title: "Abbey Road"}},
|
||||
},
|
||||
browseByMBID: map[string][]MBRelease{
|
||||
"rg-decoy": {{
|
||||
MBID: "rel-decoy", Title: "Abbey Road",
|
||||
Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Something Else Entirely", LengthMillis: 111000},
|
||||
{Position: 2, Title: "Not It Either", LengthMillis: 122000},
|
||||
},
|
||||
}},
|
||||
"rg-real": {{
|
||||
MBID: "rel-real", Title: "Abbey Road", Status: "Official",
|
||||
Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Come Together", LengthMillis: 259000},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMB: %v", err)
|
||||
}
|
||||
|
||||
if len(cands) != 2 { //nolint:mnd
|
||||
t.Fatalf("expected merged candidates from both steps, got %d", len(cands))
|
||||
}
|
||||
|
||||
if len(fake.queries) < 2 { //nolint:mnd
|
||||
t.Errorf(
|
||||
"cascade should have continued past the decoy step, got %d queries",
|
||||
len(fake.queries),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_CascadeBrowsesEachReleaseGroupOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The same release group surfacing at multiple cascade steps must
|
||||
// only be browsed (and returned) once.
|
||||
fake := &fakeMBClient{
|
||||
searchByStep: map[int][]MBReleaseGroupHit{
|
||||
0: {{MBID: "rg-dup", Title: "Abbey Road"}},
|
||||
1: {{MBID: "rg-dup", Title: "Abbey Road"}},
|
||||
2: {{MBID: "rg-dup", Title: "Abbey Road"}},
|
||||
3: {{MBID: "rg-dup", Title: "Abbey Road"}},
|
||||
},
|
||||
browseByMBID: map[string][]MBRelease{
|
||||
// Poor track match so the cascade keeps going.
|
||||
"rg-dup": {{
|
||||
MBID: "rel-dup", Title: "Abbey Road",
|
||||
Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Unrelated", LengthMillis: 100000},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMB: %v", err)
|
||||
}
|
||||
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated candidate, got %d", len(cands))
|
||||
}
|
||||
|
||||
if fake.browseCalls["rg-dup"] != 1 {
|
||||
t.Errorf("browse calls for rg-dup = %d, want 1", fake.browseCalls["rg-dup"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_SkipsImplausibleHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A hit resembling neither the album name nor the artist must
|
||||
// not cost a browse round-trip.
|
||||
fake := &fakeMBClient{
|
||||
searchByStep: map[int][]MBReleaseGroupHit{
|
||||
0: {{MBID: "rg-junk", Title: "Polka Party Hits", ArtistCredit: "Zzyzx Ensemble"}},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
if _, err := r.ResolveMB(context.Background(), abbeyRoadGroup()); err != nil {
|
||||
t.Fatalf("ResolveMB: %v", err)
|
||||
}
|
||||
|
||||
if fake.browseCalls["rg-junk"] != 0 {
|
||||
t.Errorf("junk hit was browsed %d times, want 0", fake.browseCalls["rg-junk"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_AbortsOnEmptyAlbumName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeMBClient{}
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveMB(context.Background(), "", "", 0, "")
|
||||
cands, err := r.ResolveMB(context.Background(), Group{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMB: %v", err)
|
||||
}
|
||||
@@ -191,3 +350,134 @@ func TestMBResolver_ResolveOneReleaseMBID(t *testing.T) {
|
||||
t.Errorf("expected 1 track, got %d", len(cand.Tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveByRecordingMBIDs_VotesAcrossRecordings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// rec-1 and rec-2 both appear on rel-shared; rec-1 also appears
|
||||
// on rel-solo. The shared release gets 2 votes and wins.
|
||||
fake := &fakeMBClient{
|
||||
recRelsByMBID: map[string][]MBReleaseRef{
|
||||
"rec-1": {
|
||||
{MBID: "rel-shared", Status: "Official", Date: "1969"},
|
||||
{MBID: "rel-solo", Status: "Official", Date: "1968"},
|
||||
},
|
||||
"rec-2": {
|
||||
{MBID: "rel-shared", Status: "Official", Date: "1969"},
|
||||
},
|
||||
},
|
||||
lookupRels: map[string]MBRelease{
|
||||
"rel-shared": {
|
||||
MBID: "rel-shared", Title: "Abbey Road",
|
||||
Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Come Together", MBID: "rec-1"},
|
||||
{Position: 2, Title: "Something", MBID: "rec-2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveByRecordingMBIDs(
|
||||
context.Background(), []string{"rec-1", "rec-2"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveByRecordingMBIDs: %v", err)
|
||||
}
|
||||
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("expected 1 candidate, got %d", len(cands))
|
||||
}
|
||||
|
||||
if cands[0].ReleaseMBID != "rel-shared" {
|
||||
t.Errorf("release = %q, want 'rel-shared' (2 votes beats 1)", cands[0].ReleaseMBID)
|
||||
}
|
||||
|
||||
if cands[0].Provenance != "id" {
|
||||
t.Errorf("provenance = %q, want 'id'", cands[0].Provenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveByRecordingMBIDs_NoResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeMBClient{recRelsByMBID: map[string][]MBReleaseRef{}}
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveByRecordingMBIDs(context.Background(), []string{"rec-x"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveByRecordingMBIDs: %v", err)
|
||||
}
|
||||
|
||||
if cands != nil {
|
||||
t.Errorf("expected nil candidates for unknown recordings, got %d", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRepresentativeRelease(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
refs := []MBReleaseRef{
|
||||
{MBID: "promo-1970", Status: "Promotion", Date: "1970"},
|
||||
{MBID: "official-1975", Status: "Official", Date: "1975"},
|
||||
{MBID: "official-1969", Status: "Official", Date: "1969"},
|
||||
{MBID: "undated-official", Status: "Official", Date: ""},
|
||||
}
|
||||
|
||||
// Official beats Promotion; among Official the earliest date wins.
|
||||
best := pickRepresentativeRelease(refs)
|
||||
if best.MBID != "official-1969" {
|
||||
t.Errorf("best = %q, want 'official-1969'", best.MBID)
|
||||
}
|
||||
|
||||
if got := pickRepresentativeRelease(nil); got.MBID != "" {
|
||||
t.Errorf("empty input = %+v, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOneRecordingMBID_ResolvesToRepresentativeRelease(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeMBClient{
|
||||
recRelsByMBID: map[string][]MBReleaseRef{
|
||||
"rec-1": {
|
||||
{MBID: "rel-reissue", Status: "Official", Date: "2011"},
|
||||
{MBID: "rel-original", Status: "Official", Date: "1979"},
|
||||
},
|
||||
},
|
||||
lookupRels: map[string]MBRelease{
|
||||
"rel-original": {
|
||||
MBID: "rel-original", Title: "The Wall",
|
||||
Tracks: []CandidateTrack{{Position: 1, Title: "Hey You"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cand, err := r.ResolveOneRecordingMBID(context.Background(), "rec-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveOneRecordingMBID: %v", err)
|
||||
}
|
||||
|
||||
// Earliest official release chosen and resolved in full.
|
||||
if cand.ReleaseMBID != "rel-original" {
|
||||
t.Errorf("release = %q, want 'rel-original'", cand.ReleaseMBID)
|
||||
}
|
||||
|
||||
if cand.Provenance != "search-recording" {
|
||||
t.Errorf("provenance = %q, want 'search-recording'", cand.Provenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOneRecordingMBID_NoReleases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeMBClient{recRelsByMBID: map[string][]MBReleaseRef{}}
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
if _, err := r.ResolveOneRecordingMBID(context.Background(), "rec-x"); err == nil {
|
||||
t.Fatal("expected error for recording with no releases")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,60 +5,128 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// qualifierAlternation is the regex alternation of parenthesized /
|
||||
// dash-suffixed qualifiers that MB sometimes adds to titles but user
|
||||
// tags often omit — e.g. `Remastered 2009`, `Bonus Track`, `feat. X`.
|
||||
// Shared between the parenthesized and dash-suffix qualifier patterns.
|
||||
const qualifierAlternation = `remaster(ed)?(\s+\d{4})?|` +
|
||||
`re-?master(ed)?(\s+\d{4})?|` +
|
||||
`\d{4}\s+remaster(ed)?|` +
|
||||
`deluxe(\s+(edition|version))?|` +
|
||||
`expanded(\s+(edition|version))?|` +
|
||||
`anniversary(\s+(edition|version))?|` +
|
||||
`explicit|` +
|
||||
`clean|` +
|
||||
`bonus\s+track|` +
|
||||
`live(\s+at\s+[^\)\]]*)?|` +
|
||||
`acoustic|` +
|
||||
`radio\s+edit|` +
|
||||
`single\s+version|` +
|
||||
`album\s+version|` +
|
||||
`original\s+mix|` +
|
||||
`instrumental|` +
|
||||
`demo|` +
|
||||
`mono|` +
|
||||
`stereo|` +
|
||||
`feat\.?\s+[^\)\]]*|` +
|
||||
`featuring\s+[^\)\]]*|` +
|
||||
`ft\.?\s+[^\)\]]*`
|
||||
|
||||
// qualifierPattern matches common parenthesized / bracketed
|
||||
// qualifiers that MB sometimes adds to titles but user tags often
|
||||
// omit — e.g. `(Remastered 2009)`, `[Bonus Track]`, `(feat. X)`.
|
||||
// Case-insensitive. Only strips when the qualifier is at a
|
||||
// word-boundary to avoid mangling titles like "Untitled (1)".
|
||||
// qualifiers. Case-insensitive. Only strips when the qualifier is
|
||||
// at a word-boundary to avoid mangling titles like "Untitled (1)".
|
||||
var qualifierPattern = regexp.MustCompile(
|
||||
`(?i)\s*[\(\[]\s*(` +
|
||||
`remaster(ed)?(\s+\d{4})?|` +
|
||||
`re-?master(ed)?(\s+\d{4})?|` +
|
||||
`\d{4}\s+remaster(ed)?|` +
|
||||
`deluxe(\s+edition)?|` +
|
||||
`expanded(\s+edition)?|` +
|
||||
`explicit|` +
|
||||
`clean|` +
|
||||
`bonus\s+track|` +
|
||||
`live(\s+at\s+[^\)\]]*)?|` +
|
||||
`acoustic|` +
|
||||
`radio\s+edit|` +
|
||||
`single\s+version|` +
|
||||
`album\s+version|` +
|
||||
`instrumental|` +
|
||||
`demo|` +
|
||||
`mono|` +
|
||||
`stereo|` +
|
||||
`feat\.?\s+[^\)\]]*|` +
|
||||
`featuring\s+[^\)\]]*|` +
|
||||
`ft\.?\s+[^\)\]]*` +
|
||||
`)\s*[\)\]]`,
|
||||
`(?i)\s*[\(\[]\s*(` + qualifierAlternation + `)\s*[\)\]]`,
|
||||
)
|
||||
|
||||
// dashQualifierPattern matches the dash-suffix form of the same
|
||||
// qualifiers — `Song - 2009 Remaster`, `Song – Radio Edit` — which
|
||||
// streaming-service-derived tags use instead of parentheses.
|
||||
var dashQualifierPattern = regexp.MustCompile(
|
||||
`(?i)\s+[-–—]\s+(` + qualifierAlternation + `)\s*$`,
|
||||
)
|
||||
|
||||
// whitespaceCollapse replaces runs of whitespace with a single space.
|
||||
var whitespaceCollapse = regexp.MustCompile(`\s+`)
|
||||
|
||||
// asciiSpecials maps letters that unicode decomposition alone can't
|
||||
// reduce to ASCII (they aren't combining-mark compositions).
|
||||
var asciiSpecials = strings.NewReplacer(
|
||||
"ß", "ss", "ẞ", "SS",
|
||||
"æ", "ae", "Æ", "AE",
|
||||
"œ", "oe", "Œ", "OE",
|
||||
"ø", "o", "Ø", "O",
|
||||
"đ", "d", "Đ", "D",
|
||||
"ð", "d", "Ð", "D",
|
||||
"þ", "th", "Þ", "Th",
|
||||
"ł", "l", "Ł", "L",
|
||||
"ı", "i",
|
||||
)
|
||||
|
||||
// asciiFold transliterates accented characters to their closest
|
||||
// ASCII equivalent ("Beyoncé" → "Beyonce", "Björk" → "Bjork") so
|
||||
// diacritic differences between user tags and MB data don't count
|
||||
// as edits. Non-Latin scripts pass through unchanged.
|
||||
func asciiFold(s string) string {
|
||||
// Fast path: nothing to fold in pure-ASCII strings.
|
||||
ascii := true
|
||||
|
||||
for i := range len(s) {
|
||||
if s[i] >= 0x80 {
|
||||
ascii = false
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ascii {
|
||||
return s
|
||||
}
|
||||
|
||||
s = asciiSpecials.Replace(s)
|
||||
|
||||
// NFKD splits accented letters into base + combining marks;
|
||||
// dropping the marks (unicode.Mn) leaves the base letter. The
|
||||
// chain is stateful, so build it per call — it's cheap and this
|
||||
// keeps concurrent scorers safe.
|
||||
t := transform.Chain(norm.NFKD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
|
||||
folded, _, err := transform.String(t, s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
|
||||
return folded
|
||||
}
|
||||
|
||||
// Normalize returns a comparison-friendly form of a title or
|
||||
// artist-credit string:
|
||||
//
|
||||
// - NFC unicode composition
|
||||
// - ASCII transliteration (accents folded)
|
||||
// - qualifier suffixes stripped (see qualifierPattern)
|
||||
// - all punctuation dropped
|
||||
// - "&" replaced with "and"
|
||||
// - all remaining punctuation dropped
|
||||
// - case folded (lowercased)
|
||||
// - whitespace collapsed and trimmed
|
||||
//
|
||||
// The result is not intended to be human-readable — only used for
|
||||
// equality and edit-distance comparisons inside the scorer.
|
||||
// equality comparisons (local candidate matching) and search query
|
||||
// building. Fuzzy comparisons go through titleSimilarity, which
|
||||
// keeps more structure.
|
||||
func Normalize(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
s = norm.NFC.String(s)
|
||||
s = asciiFold(s)
|
||||
s = qualifierPattern.ReplaceAllString(s, "")
|
||||
s = dashQualifierPattern.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, "&", " and ")
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
|
||||
@@ -20,12 +20,16 @@ func TestNormalize(t *testing.T) {
|
||||
"Sgt. Pepper's Lonely Hearts Club Band!",
|
||||
"sgt peppers lonely hearts club band",
|
||||
},
|
||||
"NFC unicode": {"Beyoncé", "beyoncé"},
|
||||
"accents fold to ascii": {"Beyoncé", "beyonce"},
|
||||
"eszett folds": {"Motörhead & Björk", "motorhead and bjork"},
|
||||
"ampersand becomes and": {"Simon & Garfunkel", "simon and garfunkel"},
|
||||
"remastered qualifier": {"Abbey Road (Remastered 2009)", "abbey road"},
|
||||
"remaster no year": {"Abbey Road (Remaster)", "abbey road"},
|
||||
"explicit qualifier": {"Lemonade [Explicit]", "lemonade"},
|
||||
"feat qualifier": {"Yellow (feat. Coldplay)", "yellow"},
|
||||
"bonus track qualifier": {"Hey Jude [Bonus Track]", "hey jude"},
|
||||
"dash suffix qualifier": {"Hey Jude - 2015 Remaster", "hey jude"},
|
||||
"dash radio edit": {"One More Time - Radio Edit", "one more time"},
|
||||
"collapse whitespace": {" Abbey Road ", "abbey road"},
|
||||
"non-ascii digits kept": {"Track 7", "track 7"},
|
||||
"numbered title not mangled": {"Untitled (1)", "untitled 1"},
|
||||
|
||||
+315
-37
@@ -8,23 +8,90 @@ import (
|
||||
|
||||
// Release-level scoring weights. Aggregate track score is the
|
||||
// dominant signal — the release-level signals are tie-breakers
|
||||
// when the track alignment is roughly comparable.
|
||||
// when the track alignment is roughly comparable. Weights sum to
|
||||
// 1.0 so a perfect candidate scores 1.0 before the evidence scale.
|
||||
const (
|
||||
weightTrackAggregate = 0.70
|
||||
weightTrackCountMatch = 0.15
|
||||
weightReleaseMeta = 0.15 // Official + country averaged
|
||||
weightTrackAggregate = 0.55
|
||||
weightArtist = 0.12 // album-artist vs candidate artist-credit
|
||||
weightAlbumTitle = 0.10 // folder album name vs candidate release title
|
||||
weightTrackCountMatch = 0.13
|
||||
weightReleaseMeta = 0.10 // official + country + RG type, averaged
|
||||
|
||||
// Country preference: a very mild nudge toward releases from
|
||||
// the user's locale. Will become a config option in 012.
|
||||
preferredCountry = "US"
|
||||
|
||||
// Evidence scaling: a folder with very few tracks offers little
|
||||
// corroborating signal, so even a perfect title+length match on
|
||||
// a single track is inherently less trustworthy than the same
|
||||
// match across a full album. The final release score is scaled
|
||||
// by evidenceFactor(localTrackCount): folders at or above
|
||||
// evidenceFullTracks are unscaled; smaller folders are pulled
|
||||
// toward evidenceFloor. This is the "harsher on singletons"
|
||||
// lever — a single can never present as a near-certain match on
|
||||
// its own, which is also why 012 keeps singletons out of
|
||||
// auto-accept entirely.
|
||||
evidenceFloor = 0.85
|
||||
evidenceFullTracks = 3
|
||||
)
|
||||
|
||||
// vaNames are artist strings that signal "various artists" — used
|
||||
// both to detect VA-likely folders and to recognize VA candidate
|
||||
// credits. Mirrors beets' VA_ARTISTS.
|
||||
var vaNames = map[string]bool{
|
||||
"various artists": true,
|
||||
"various": true,
|
||||
"va": true,
|
||||
"v a": true, // "V.A." after Normalize
|
||||
"unknown": true,
|
||||
}
|
||||
|
||||
// isVAName reports whether an artist string reads as "various
|
||||
// artists". Empty strings are NOT VA — they're unknown, which the
|
||||
// artist term already treats as neutral.
|
||||
func isVAName(s string) bool {
|
||||
return vaNames[Normalize(s)]
|
||||
}
|
||||
|
||||
// vaLikely reports whether a group is probably a various-artists
|
||||
// compilation: the album-artist tag says so outright, or the
|
||||
// per-track artists have no consensus (≥2 distinct values, or none
|
||||
// at all). Mirrors beets' va_likely heuristic.
|
||||
func vaLikely(g Group) bool {
|
||||
if isVAName(g.AlbumArtist) {
|
||||
return true
|
||||
}
|
||||
|
||||
if g.AlbumArtist != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
distinct := make(map[string]bool, 2) //nolint:mnd
|
||||
|
||||
for _, t := range g.Tracks {
|
||||
if t.Artist == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
distinct[Normalize(t.Artist)] = true
|
||||
}
|
||||
|
||||
return len(distinct) != 1
|
||||
}
|
||||
|
||||
// ScoreCandidate fills in c.Alignments, c.Score, c.Breakdown, and
|
||||
// c.TrackCount for a single candidate against the given local
|
||||
// tracks. The returned Candidate is safe to copy — no shared
|
||||
// state with the caller's slice.
|
||||
func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candidate {
|
||||
c.Alignments = AlignTracks(local, c.Tracks)
|
||||
// c.TrackCount for a single candidate against the given group.
|
||||
// The returned Candidate is safe to copy — no shared state with
|
||||
// the caller's slice.
|
||||
func ScoreCandidate(g Group, c Candidate) Candidate {
|
||||
local := g.Tracks
|
||||
|
||||
// When the group is one disc of a multi-disc candidate, align
|
||||
// and count against that disc only — a "disc 1 of 2" folder is
|
||||
// complete for its disc, not half an album.
|
||||
targets := alignmentTargets(local, c.Tracks)
|
||||
|
||||
c.Alignments = AlignTracks(local, targets)
|
||||
|
||||
var (
|
||||
titleSum float64
|
||||
@@ -38,10 +105,19 @@ func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candid
|
||||
}
|
||||
|
||||
counted++
|
||||
titleSum += a.TitleScore
|
||||
|
||||
l := local[a.LocalIndex]
|
||||
lengthSum += lengthScore(l.LengthMillis, a.CandidateLength)
|
||||
// A recording-MBID lock is identity, not similarity: the
|
||||
// title may be garbled in the local tag, but the track IS
|
||||
// the candidate's track. Count it as a perfect title so a
|
||||
// confirmed match isn't dragged down by its own typos (the
|
||||
// UI still shows the textual diff).
|
||||
if a.IDMatch {
|
||||
titleSum++
|
||||
} else {
|
||||
titleSum += a.TitleScore
|
||||
}
|
||||
|
||||
lengthSum += a.LengthScore
|
||||
}
|
||||
|
||||
titleAvg, lengthAvg := 0.0, 0.0
|
||||
@@ -63,31 +139,213 @@ func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candid
|
||||
|
||||
trackAgg := ((titleAvg*weightTitle + lengthAvg*weightLength) / trackWeightSum) * coverage
|
||||
|
||||
trackCountScore := trackCountMatch(len(c.Tracks), localTrackCount)
|
||||
// Release-meta is just official-status + country preference,
|
||||
// averaged. We used to mix in a year bonus too, but that
|
||||
// compared candidate years against time.Now() — penalising
|
||||
// every album that wasn't from this year, regardless of how
|
||||
// well it matched the local files. See git history.
|
||||
const metaTerms = 2.0
|
||||
trackCountScore := trackCountMatch(len(targets), len(local))
|
||||
|
||||
meta := (officialBonus(c.Status) + countryBonus(c.Country)) / metaTerms
|
||||
// Artist fit: compare the folder's artist against the
|
||||
// candidate's release artist-credit. This is a SOFT signal, not
|
||||
// a gate — user artist tags are often slightly wrong (misspelled,
|
||||
// "&" vs "and", missing "feat."), so an almost-right artist still
|
||||
// matches well while a completely different artist is penalised.
|
||||
// Critically, artist is otherwise only a *search* filter (see
|
||||
// buildMBQueryCascade), and that cascade drops the artist clause
|
||||
// on its looser steps — so without this term a same-title,
|
||||
// different-artist release scores as if the artist matched.
|
||||
artistFit := artistCreditFit(groupArtist(g), c.ArtistCredit)
|
||||
|
||||
c.Score = trackAgg*weightTrackAggregate +
|
||||
// Album-title fit: the same soft-signal contract for the release
|
||||
// title. Without it, a compilation containing the same
|
||||
// recordings scores as if it WERE the album ("Greatest Hits" vs
|
||||
// the studio album with an identical tracklist).
|
||||
albumFit := albumTitleFit(g.AlbumName, c.Title)
|
||||
|
||||
// Release-meta: official-status + country preference + release-
|
||||
// group type, averaged. All mild tie-breakers. (We used to mix
|
||||
// in a year bonus too, but that compared candidate years against
|
||||
// time.Now() — see git history.)
|
||||
const metaTerms = 3.0
|
||||
|
||||
meta := (officialBonus(c.Status) + countryBonus(c.Country) + rgTypeBonus(c.PrimaryType)) /
|
||||
metaTerms
|
||||
|
||||
// Evidence scaling applies only to MusicBrainz candidates. A
|
||||
// local candidate is the *same* release-group already tagged with
|
||||
// MBIDs in another library — its confidence comes from that
|
||||
// confirmed tagging, not from thin per-track heuristics, so a
|
||||
// small-folder local match stays fully trusted (and keeps
|
||||
// clearing the localSufficient MB-skip short-circuit). MB
|
||||
// matches, by contrast, are fuzzy search results where a
|
||||
// single-track folder genuinely offers little corroboration.
|
||||
evidence := 1.0
|
||||
if c.Source == SourceMusicBrainz {
|
||||
evidence = evidenceFactor(len(local))
|
||||
}
|
||||
|
||||
c.Score = (trackAgg*weightTrackAggregate +
|
||||
artistFit*weightArtist +
|
||||
albumFit*weightAlbumTitle +
|
||||
trackCountScore*weightTrackCountMatch +
|
||||
meta*weightReleaseMeta
|
||||
meta*weightReleaseMeta) * evidence
|
||||
|
||||
c.Breakdown = ScoreBreakdown{
|
||||
TitleAvg: titleAvg,
|
||||
LengthAvg: lengthAvg,
|
||||
ArtistFit: artistFit,
|
||||
AlbumFit: albumFit,
|
||||
TrackCountFit: trackCountScore,
|
||||
ReleaseMeta: meta,
|
||||
Evidence: evidence,
|
||||
}
|
||||
c.TrackCount = len(c.Tracks)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// alignmentTargets returns the candidate tracks the local group
|
||||
// should be aligned against. When every local track sits on the
|
||||
// same disc D and the candidate spans multiple discs including D,
|
||||
// only disc D's tracks are targets — the group key is per-disc, so
|
||||
// a single-disc folder must not be penalised for "missing" the
|
||||
// candidate's other discs.
|
||||
func alignmentTargets(local []LocalTrack, cands []CandidateTrack) []CandidateTrack {
|
||||
disc := uniformDisc(local)
|
||||
if disc == 0 {
|
||||
return cands
|
||||
}
|
||||
|
||||
var (
|
||||
onDisc int
|
||||
multiDiscs bool
|
||||
)
|
||||
|
||||
for _, c := range cands {
|
||||
if c.DiscNumber == disc {
|
||||
onDisc++
|
||||
} else if c.DiscNumber > 0 {
|
||||
multiDiscs = true
|
||||
}
|
||||
}
|
||||
|
||||
if !multiDiscs || onDisc == 0 {
|
||||
return cands
|
||||
}
|
||||
|
||||
out := make([]CandidateTrack, 0, onDisc)
|
||||
|
||||
for _, c := range cands {
|
||||
if c.DiscNumber == disc {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// uniformDisc returns the disc number shared by every local track,
|
||||
// or 0 when discs are mixed or unknown.
|
||||
func uniformDisc(local []LocalTrack) int {
|
||||
disc := 0
|
||||
|
||||
for _, t := range local {
|
||||
switch {
|
||||
case t.DiscNumber <= 0:
|
||||
return 0
|
||||
case disc == 0:
|
||||
disc = t.DiscNumber
|
||||
case t.DiscNumber != disc:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return disc
|
||||
}
|
||||
|
||||
// groupArtist returns the artist string to compare candidates
|
||||
// against: the tagging item's album-artist when it's a real name,
|
||||
// otherwise the most common per-track artist. Returns "" when
|
||||
// nothing is known (neutral, no penalty).
|
||||
func groupArtist(g Group) string {
|
||||
if g.AlbumArtist != "" && !isVAName(g.AlbumArtist) {
|
||||
return g.AlbumArtist
|
||||
}
|
||||
|
||||
return dominantArtist(g.Tracks)
|
||||
}
|
||||
|
||||
// dominantArtist returns the most common non-empty per-track artist
|
||||
// in a local group. Ties resolve to the first-seen value so the
|
||||
// result is deterministic. Returns "" when no track has an artist,
|
||||
// which artistCreditFit treats as "unknown, no penalty".
|
||||
func dominantArtist(local []LocalTrack) string {
|
||||
counts := make(map[string]int, len(local))
|
||||
|
||||
var (
|
||||
best string
|
||||
bestCount int
|
||||
)
|
||||
|
||||
for _, t := range local {
|
||||
if t.Artist == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
counts[t.Artist]++
|
||||
if counts[t.Artist] > bestCount {
|
||||
best = t.Artist
|
||||
bestCount = counts[t.Artist]
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// artistCreditFit scores how well a folder's artist matches a
|
||||
// candidate's release artist-credit, in [0, 1]. Returns 1.0 (no
|
||||
// penalty) when either side is unknown or reads as "various
|
||||
// artists": absence of artist data must not push a candidate down,
|
||||
// and VA credits are placeholders, not disagreements. Reuses the
|
||||
// edit-distance similarity so near-right artists stay high.
|
||||
func artistCreditFit(localArtist, candidateArtist string) float64 {
|
||||
if localArtist == "" || candidateArtist == "" {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
if isVAName(localArtist) || isVAName(candidateArtist) {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
return titleSimilarity(localArtist, candidateArtist)
|
||||
}
|
||||
|
||||
// albumTitleFit scores how well the folder's album name matches the
|
||||
// candidate's release title, in [0, 1]. Neutral (1.0) when either
|
||||
// side is unknown — same soft-signal contract as artistCreditFit.
|
||||
func albumTitleFit(albumName, candidateTitle string) float64 {
|
||||
if albumName == "" || candidateTitle == "" {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
return titleSimilarity(albumName, candidateTitle)
|
||||
}
|
||||
|
||||
// evidenceFactor scales the release score down when a folder has too
|
||||
// few tracks to corroborate the match. Folders at or above
|
||||
// evidenceFullTracks are unscaled (1.0); a single-track folder is
|
||||
// pulled to evidenceFloor; two tracks land halfway. See the
|
||||
// evidence-scaling note on the weight constants.
|
||||
func evidenceFactor(localTrackCount int) float64 {
|
||||
if localTrackCount >= evidenceFullTracks {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
if localTrackCount <= 1 {
|
||||
return evidenceFloor
|
||||
}
|
||||
|
||||
span := float64(localTrackCount-1) / float64(evidenceFullTracks-1)
|
||||
|
||||
return evidenceFloor + (1.0-evidenceFloor)*span
|
||||
}
|
||||
|
||||
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
|
||||
// linear between.
|
||||
func trackCountMatch(a, b int) float64 {
|
||||
@@ -104,10 +362,7 @@ func trackCountMatch(a, b int) float64 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
larger := a
|
||||
if b > larger {
|
||||
larger = b
|
||||
}
|
||||
larger := max(a, b)
|
||||
|
||||
frac := float64(diff) / float64(larger)
|
||||
|
||||
@@ -124,14 +379,11 @@ func trackCountMatch(a, b int) float64 {
|
||||
func officialBonus(status string) float64 {
|
||||
const partial = 0.5
|
||||
|
||||
switch strings.ToLower(status) {
|
||||
case "official":
|
||||
if strings.EqualFold(status, "official") {
|
||||
return 1.0
|
||||
case "":
|
||||
return partial
|
||||
default:
|
||||
return partial
|
||||
}
|
||||
|
||||
return partial
|
||||
}
|
||||
|
||||
// countryBonus gives a mild nudge toward releases from the
|
||||
@@ -153,6 +405,32 @@ func countryBonus(country string) float64 {
|
||||
return neutral
|
||||
}
|
||||
|
||||
// rgTypeBonus nudges toward studio albums over compilations and
|
||||
// live releases when the track evidence is otherwise comparable —
|
||||
// Picard weights release type heavily for the same reason. The
|
||||
// nudge is mild: a genuine single folder still matches its Single
|
||||
// release because track count and alignment dominate. Unknown
|
||||
// types (including all local candidates) sit near the top so the
|
||||
// term only separates candidates we positively know differ.
|
||||
func rgTypeBonus(primaryType string) float64 {
|
||||
switch strings.ToLower(primaryType) {
|
||||
case "album":
|
||||
return 1.0
|
||||
case "ep":
|
||||
return 0.9
|
||||
case "single":
|
||||
return 0.85
|
||||
case "":
|
||||
return 0.85
|
||||
case "soundtrack":
|
||||
return 0.7
|
||||
case "compilation", "live":
|
||||
return 0.6
|
||||
default:
|
||||
return 0.7
|
||||
}
|
||||
}
|
||||
|
||||
// parseYear pulls the first 4-digit year out of date strings like
|
||||
// "2009", "2009-05-18", "".
|
||||
func parseYear(date string) int {
|
||||
@@ -168,13 +446,13 @@ func parseYear(date string) int {
|
||||
return y
|
||||
}
|
||||
|
||||
// RankCandidates scores each candidate against the local tracks
|
||||
// and returns a new slice sorted descending by score. Input slice
|
||||
// is not modified.
|
||||
func RankCandidates(local []LocalTrack, candidates []Candidate) []Candidate {
|
||||
// RankCandidates scores each candidate against the group and
|
||||
// returns a new slice sorted descending by score. Input slice is
|
||||
// not modified.
|
||||
func RankCandidates(g Group, candidates []Candidate) []Candidate {
|
||||
scored := make([]Candidate, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
scored = append(scored, ScoreCandidate(local, c, len(local)))
|
||||
scored = append(scored, ScoreCandidate(g, c))
|
||||
}
|
||||
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
|
||||
@@ -37,7 +37,10 @@ func TestRankCandidates_PrefersExactTrackCountMatch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(local, []autotag.Candidate{longer, matching})
|
||||
ranked := autotag.RankCandidates(
|
||||
autotag.Group{Tracks: local},
|
||||
[]autotag.Candidate{longer, matching},
|
||||
)
|
||||
if ranked[0].ReleaseMBID != "exact" {
|
||||
t.Errorf(
|
||||
"top = %q (score %.2f vs %.2f), want 'exact'",
|
||||
@@ -69,7 +72,10 @@ func TestRankCandidates_PrefersOfficial(t *testing.T) {
|
||||
Tracks: tracks,
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(local, []autotag.Candidate{promo, official})
|
||||
ranked := autotag.RankCandidates(
|
||||
autotag.Group{Tracks: local},
|
||||
[]autotag.Candidate{promo, official},
|
||||
)
|
||||
if ranked[0].ReleaseMBID != "official" {
|
||||
t.Errorf("top = %q, want 'official'", ranked[0].ReleaseMBID)
|
||||
}
|
||||
@@ -98,7 +104,7 @@ func TestRankCandidates_MultiDisc(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
|
||||
ranked := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{cand})
|
||||
if ranked[0].Score < 0.75 { //nolint:mnd
|
||||
t.Errorf("multi-disc exact match = %.2f, want >= 0.75", ranked[0].Score)
|
||||
}
|
||||
@@ -127,12 +133,123 @@ func TestRankCandidates_VariousArtists(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
|
||||
ranked := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{cand})
|
||||
if ranked[0].Score < 0.75 { //nolint:mnd
|
||||
t.Errorf("VA-compilation exact track match = %.2f, want >= 0.75", ranked[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_PenalizesWrongArtistSingleton(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The reported false positive: a single with a generic title
|
||||
// matched a same-titled song by a DIFFERENT artist with a very
|
||||
// different length, at high confidence. Artist was only a search
|
||||
// filter, never a scoring signal, so nothing pulled the wrong
|
||||
// candidate down. Now the artist term + singleton evidence
|
||||
// scaling must keep it well below a confident match.
|
||||
local := []autotag.LocalTrack{
|
||||
{Title: "Intro", Artist: "Real Artist", TrackNumber: 1, LengthMillis: 90000},
|
||||
}
|
||||
|
||||
// Same title, wrong artist, and a wildly different length — an MB
|
||||
// search hit that happens to share a common title.
|
||||
wrongArtist := autotag.Candidate{
|
||||
ReleaseMBID: "wrong",
|
||||
Title: "Intro",
|
||||
ArtistCredit: "Some Other Band",
|
||||
Status: "Official",
|
||||
Source: autotag.SourceMusicBrainz,
|
||||
Tracks: []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "Intro", LengthMillis: 240000},
|
||||
},
|
||||
}
|
||||
|
||||
// The correct release: same title, right artist, right length.
|
||||
rightArtist := autotag.Candidate{
|
||||
ReleaseMBID: "right",
|
||||
Title: "Intro",
|
||||
ArtistCredit: "Real Artist",
|
||||
Status: "Official",
|
||||
Source: autotag.SourceMusicBrainz,
|
||||
Tracks: []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "Intro", LengthMillis: 90000},
|
||||
},
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(
|
||||
autotag.Group{Tracks: local},
|
||||
[]autotag.Candidate{wrongArtist, rightArtist},
|
||||
)
|
||||
|
||||
if ranked[0].ReleaseMBID != "right" {
|
||||
t.Fatalf(
|
||||
"top = %q (%.2f vs %.2f), want 'right'",
|
||||
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
|
||||
)
|
||||
}
|
||||
|
||||
// The wrong-artist candidate must not read as a confident match.
|
||||
var wrongScore float64
|
||||
|
||||
for _, c := range ranked {
|
||||
if c.ReleaseMBID == "wrong" {
|
||||
wrongScore = c.Score
|
||||
}
|
||||
}
|
||||
|
||||
if wrongScore >= 0.75 { //nolint:mnd
|
||||
t.Errorf("wrong-artist singleton scored %.2f, want < 0.75", wrongScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_EvidenceScalingIsSourceAware(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A perfect single-track match: identical title, artist, length.
|
||||
// From MusicBrainz it should be evidence-scaled (thin corroboration
|
||||
// on one track); from a local library it should NOT be — a local
|
||||
// candidate is the same release-group already tagged with MBIDs
|
||||
// elsewhere, so its confidence is not heuristic.
|
||||
local := []autotag.LocalTrack{
|
||||
{Title: "Solo", Artist: "Someone", TrackNumber: 1, LengthMillis: 200000},
|
||||
}
|
||||
|
||||
tracks := []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "Solo", LengthMillis: 200000},
|
||||
}
|
||||
|
||||
mbCand := autotag.Candidate{
|
||||
ReleaseMBID: "mb", ArtistCredit: "Someone", Status: "Official",
|
||||
Source: autotag.SourceMusicBrainz, Tracks: tracks,
|
||||
}
|
||||
localCand := autotag.Candidate{
|
||||
ReleaseMBID: "local", ArtistCredit: "Someone", Status: "Official",
|
||||
Source: autotag.SourceLocal, Tracks: tracks,
|
||||
}
|
||||
|
||||
scoredMB := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{mbCand})[0]
|
||||
scoredLocal := autotag.RankCandidates(autotag.Group{Tracks: local}, []autotag.Candidate{localCand})[0]
|
||||
|
||||
if scoredMB.Breakdown.Evidence >= 1.0 {
|
||||
t.Errorf("MB singleton evidence = %.3f, want < 1.0", scoredMB.Breakdown.Evidence)
|
||||
}
|
||||
|
||||
if scoredLocal.Breakdown.Evidence < 1.0 {
|
||||
t.Errorf(
|
||||
"local singleton evidence = %.3f, want 1.0 (not scaled)",
|
||||
scoredLocal.Breakdown.Evidence,
|
||||
)
|
||||
}
|
||||
|
||||
if scoredLocal.Score <= scoredMB.Score {
|
||||
t.Errorf(
|
||||
"local perfect single (%.3f) should outscore the evidence-scaled MB single (%.3f)",
|
||||
scoredLocal.Score, scoredMB.Score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -164,7 +281,10 @@ func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(local, []autotag.Candidate{queen, eagles})
|
||||
ranked := autotag.RankCandidates(
|
||||
autotag.Group{Tracks: local},
|
||||
[]autotag.Candidate{queen, eagles},
|
||||
)
|
||||
if ranked[0].ReleaseMBID != "eagles-gh" {
|
||||
t.Errorf(
|
||||
"top = %q (%.2f vs %.2f), want 'eagles-gh'",
|
||||
@@ -172,3 +292,180 @@ func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_AlbumTitleSeparatesCompilation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Identical tracklists: the studio album and a greatest-hits comp
|
||||
// that contains the same recordings. Track alignment can't
|
||||
// separate them — the folder's album name must.
|
||||
local := []autotag.LocalTrack{
|
||||
{Title: "Song A", Artist: "The Band", TrackNumber: 1, LengthMillis: 200000},
|
||||
{Title: "Song B", Artist: "The Band", TrackNumber: 2, LengthMillis: 210000},
|
||||
{Title: "Song C", Artist: "The Band", TrackNumber: 3, LengthMillis: 195000},
|
||||
}
|
||||
|
||||
tracks := []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "Song A", LengthMillis: 200000},
|
||||
{Position: 2, Title: "Song B", LengthMillis: 210000},
|
||||
{Position: 3, Title: "Song C", LengthMillis: 195000},
|
||||
}
|
||||
|
||||
album := autotag.Candidate{
|
||||
ReleaseMBID: "studio", Title: "The Studio Album",
|
||||
ArtistCredit: "The Band", Status: "Official", Tracks: tracks,
|
||||
}
|
||||
comp := autotag.Candidate{
|
||||
ReleaseMBID: "comp", Title: "Greatest Hits",
|
||||
ArtistCredit: "The Band", Status: "Official", Tracks: tracks,
|
||||
}
|
||||
|
||||
g := autotag.Group{
|
||||
AlbumName: "The Studio Album", AlbumArtist: "The Band", Tracks: local,
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(g, []autotag.Candidate{comp, album})
|
||||
if ranked[0].ReleaseMBID != "studio" {
|
||||
t.Errorf(
|
||||
"top = %q (%.3f vs %.3f), want 'studio' (album-title term)",
|
||||
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_ReleaseGroupTypeBreaksTies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Same tracks, same title — one RG is an Album, the other a
|
||||
// Compilation. Type preference should break the tie toward the
|
||||
// studio album (Picard weights release type for the same reason).
|
||||
local := []autotag.LocalTrack{
|
||||
{Title: "Song A", TrackNumber: 1, LengthMillis: 200000},
|
||||
{Title: "Song B", TrackNumber: 2, LengthMillis: 210000},
|
||||
{Title: "Song C", TrackNumber: 3, LengthMillis: 195000},
|
||||
}
|
||||
|
||||
tracks := []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "Song A", LengthMillis: 200000},
|
||||
{Position: 2, Title: "Song B", LengthMillis: 210000},
|
||||
{Position: 3, Title: "Song C", LengthMillis: 195000},
|
||||
}
|
||||
|
||||
album := autotag.Candidate{
|
||||
ReleaseMBID: "album", Title: "X", Status: "Official",
|
||||
PrimaryType: "Album", Tracks: tracks,
|
||||
}
|
||||
comp := autotag.Candidate{
|
||||
ReleaseMBID: "comp", Title: "X", Status: "Official",
|
||||
PrimaryType: "Compilation", Tracks: tracks,
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(
|
||||
autotag.Group{Tracks: local}, []autotag.Candidate{comp, album},
|
||||
)
|
||||
if ranked[0].ReleaseMBID != "album" {
|
||||
t.Errorf(
|
||||
"top = %q (%.3f vs %.3f), want 'album' (RG type preference)",
|
||||
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_SingleDiscGroupAgainstMultiDiscRelease(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The group key is per-disc, so "disc 1 of 2" folders score
|
||||
// against multi-disc releases. Alignment and track count must
|
||||
// compare against disc 1's tracks only — not get punished for
|
||||
// "missing" all of disc 2.
|
||||
local := []autotag.LocalTrack{
|
||||
{Title: "D1T1", DiscNumber: 1, TrackNumber: 1, LengthMillis: 200000},
|
||||
{Title: "D1T2", DiscNumber: 1, TrackNumber: 2, LengthMillis: 210000},
|
||||
{Title: "D1T3", DiscNumber: 1, TrackNumber: 3, LengthMillis: 195000},
|
||||
}
|
||||
|
||||
cand := autotag.Candidate{
|
||||
ReleaseMBID: "2disc",
|
||||
Status: "Official",
|
||||
Tracks: []autotag.CandidateTrack{
|
||||
{Position: 1, DiscNumber: 1, Title: "D1T1", LengthMillis: 200000},
|
||||
{Position: 2, DiscNumber: 1, Title: "D1T2", LengthMillis: 210000},
|
||||
{Position: 3, DiscNumber: 1, Title: "D1T3", LengthMillis: 195000},
|
||||
{Position: 1, DiscNumber: 2, Title: "D2T1", LengthMillis: 220000},
|
||||
{Position: 2, DiscNumber: 2, Title: "D2T2", LengthMillis: 230000},
|
||||
{Position: 3, DiscNumber: 2, Title: "D2T3", LengthMillis: 240000},
|
||||
},
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(
|
||||
autotag.Group{Tracks: local}, []autotag.Candidate{cand},
|
||||
)
|
||||
|
||||
top := ranked[0]
|
||||
if top.Score < 0.85 { //nolint:mnd
|
||||
t.Errorf("disc-1 folder vs 2-disc release = %.3f, want >= 0.85", top.Score)
|
||||
}
|
||||
|
||||
if top.Breakdown.TrackCountFit != 1.0 {
|
||||
t.Errorf(
|
||||
"track count fit = %.2f, want 1.0 (counted against disc 1 only)",
|
||||
top.Breakdown.TrackCountFit,
|
||||
)
|
||||
}
|
||||
|
||||
// No "missing" rows for disc 2 — the folder is complete for its
|
||||
// disc.
|
||||
for _, a := range top.Alignments {
|
||||
if a.Status == autotag.AlignmentMissing {
|
||||
t.Errorf("unexpected missing alignment for %q", a.CandidateTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_RecordingMBIDLocksAlignment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The local title is garbled, but its recording MBID matches a
|
||||
// candidate track — identity beats similarity: the pair must
|
||||
// align, count as matched, and not drag the title average down.
|
||||
local := []autotag.LocalTrack{
|
||||
{Title: "trck 01", RecordingMBID: "rec-a", TrackNumber: 1, LengthMillis: 200000},
|
||||
{Title: "Song B", TrackNumber: 2, LengthMillis: 210000},
|
||||
{Title: "Song C", TrackNumber: 3, LengthMillis: 195000},
|
||||
}
|
||||
|
||||
cand := autotag.Candidate{
|
||||
ReleaseMBID: "rel",
|
||||
Status: "Official",
|
||||
Tracks: []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "Song A", LengthMillis: 200000, MBID: "rec-a"},
|
||||
{Position: 2, Title: "Song B", LengthMillis: 210000},
|
||||
{Position: 3, Title: "Song C", LengthMillis: 195000},
|
||||
},
|
||||
}
|
||||
|
||||
ranked := autotag.RankCandidates(
|
||||
autotag.Group{Tracks: local}, []autotag.Candidate{cand},
|
||||
)
|
||||
|
||||
top := ranked[0]
|
||||
|
||||
var locked *autotag.TrackAlignment
|
||||
|
||||
for i := range top.Alignments {
|
||||
if top.Alignments[i].LocalIndex == 0 {
|
||||
locked = &top.Alignments[i]
|
||||
}
|
||||
}
|
||||
|
||||
if locked == nil || locked.Status != autotag.AlignmentMatched || !locked.IDMatch {
|
||||
t.Fatalf("garbled-title track should be ID-locked matched, got %+v", locked)
|
||||
}
|
||||
|
||||
if top.Breakdown.TitleAvg < 0.99 {
|
||||
t.Errorf(
|
||||
"title avg = %.3f, want ~1.0 (ID-locked pair counts as perfect title)",
|
||||
top.Breakdown.TitleAvg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package autotag
|
||||
|
||||
// Recommendation is a qualitative confidence tier for a group's
|
||||
// ranked candidates — the piece a raw score can't express on its
|
||||
// own. Modeled on beets' Recommendation enum: the tier starts from
|
||||
// the top candidate's absolute score and is then CAPPED by defects
|
||||
// (ambiguity with a different release group, missing/unmatched
|
||||
// tracks, thin evidence). Auto-accept (plan 011) should require
|
||||
// RecommendationStrong; the review UI can badge the rest.
|
||||
type Recommendation string
|
||||
|
||||
// Recommendation tiers, weakest to strongest.
|
||||
const (
|
||||
RecommendationNone Recommendation = "none"
|
||||
RecommendationLow Recommendation = "low"
|
||||
RecommendationMedium Recommendation = "medium"
|
||||
RecommendationStrong Recommendation = "strong"
|
||||
)
|
||||
|
||||
const (
|
||||
// Absolute score tiers.
|
||||
strongScoreThresh = 0.90
|
||||
mediumScoreThresh = 0.75
|
||||
|
||||
// A runner-up from a DIFFERENT release group within this margin
|
||||
// of the top score makes the match ambiguous — two genuinely
|
||||
// different albums both fit, so a human should look. Editions
|
||||
// of the same release group are expected to score nearly
|
||||
// identically and never count as ambiguity.
|
||||
ambiguityMargin = 0.05
|
||||
)
|
||||
|
||||
// Recommend derives the confidence tier for a ranked candidate
|
||||
// list. candidates must already be sorted best-first (the shape
|
||||
// RankCandidates returns).
|
||||
func Recommend(g Group, candidates []Candidate) Recommendation {
|
||||
if len(candidates) == 0 {
|
||||
return RecommendationNone
|
||||
}
|
||||
|
||||
top := candidates[0]
|
||||
|
||||
var rec Recommendation
|
||||
|
||||
switch {
|
||||
case top.Score >= strongScoreThresh:
|
||||
rec = RecommendationStrong
|
||||
case top.Score >= mediumScoreThresh:
|
||||
rec = RecommendationMedium
|
||||
default:
|
||||
return RecommendationLow
|
||||
}
|
||||
|
||||
// Cap: a different release group scoring within the ambiguity
|
||||
// margin means the score alone can't pick between two albums.
|
||||
if rivalWithinMargin(top, candidates[1:]) {
|
||||
rec = minRecommendation(rec, RecommendationMedium)
|
||||
}
|
||||
|
||||
// Cap: missing or unmatched tracks mean the alignment itself is
|
||||
// incomplete, however good the matched tracks look (beets caps
|
||||
// these penalties at "medium" the same way).
|
||||
for _, a := range top.Alignments {
|
||||
if a.Status == AlignmentMissing || a.Status == AlignmentUnmatched {
|
||||
rec = minRecommendation(rec, RecommendationMedium)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Cap: tiny folders can't corroborate a match strongly enough
|
||||
// to act on without review, whatever the arithmetic says.
|
||||
if len(g.Tracks) < evidenceFullTracks {
|
||||
rec = minRecommendation(rec, RecommendationMedium)
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// rivalWithinMargin reports whether any candidate from a different
|
||||
// release group scores within ambiguityMargin of the top candidate.
|
||||
func rivalWithinMargin(top Candidate, rest []Candidate) bool {
|
||||
for _, c := range rest {
|
||||
if top.Score-c.Score > ambiguityMargin {
|
||||
// Sorted descending: everything further is farther away.
|
||||
return false
|
||||
}
|
||||
|
||||
if !sameReleaseGroup(top, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// sameReleaseGroup reports whether two candidates belong to the
|
||||
// same release group — by MBID when both carry one, by normalized
|
||||
// title + artist-credit otherwise (local candidates may lack RG
|
||||
// MBIDs).
|
||||
func sameReleaseGroup(a, b Candidate) bool {
|
||||
if a.ReleaseGroupMBID != "" && b.ReleaseGroupMBID != "" {
|
||||
return a.ReleaseGroupMBID == b.ReleaseGroupMBID
|
||||
}
|
||||
|
||||
return Normalize(a.Title) == Normalize(b.Title) &&
|
||||
Normalize(a.ArtistCredit) == Normalize(b.ArtistCredit)
|
||||
}
|
||||
|
||||
// recommendationRank orders tiers for min-comparison.
|
||||
func recommendationRank(r Recommendation) int {
|
||||
switch r {
|
||||
case RecommendationNone:
|
||||
return 0
|
||||
case RecommendationLow:
|
||||
return 1
|
||||
case RecommendationMedium:
|
||||
return 2
|
||||
case RecommendationStrong:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// minRecommendation returns the weaker of two tiers.
|
||||
func minRecommendation(a, b Recommendation) Recommendation {
|
||||
if recommendationRank(a) <= recommendationRank(b) {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package autotag
|
||||
|
||||
import "testing"
|
||||
|
||||
// mkScoredCandidate builds a minimal candidate with a preset score
|
||||
// for Recommend tests — Recommend never re-scores, it only reads.
|
||||
func mkScoredCandidate(rgMBID string, score float64) Candidate {
|
||||
return Candidate{
|
||||
ReleaseMBID: "rel-" + rgMBID,
|
||||
ReleaseGroupMBID: rgMBID,
|
||||
Score: score,
|
||||
}
|
||||
}
|
||||
|
||||
func fullGroup() Group {
|
||||
return Group{Tracks: []LocalTrack{{Title: "A"}, {Title: "B"}, {Title: "C"}}}
|
||||
}
|
||||
|
||||
func TestRecommend_Tiers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := fullGroup()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cands []Candidate
|
||||
want Recommendation
|
||||
}{
|
||||
{"no candidates", nil, RecommendationNone},
|
||||
{"strong", []Candidate{mkScoredCandidate("rg1", 0.95)}, RecommendationStrong},
|
||||
{"medium", []Candidate{mkScoredCandidate("rg1", 0.80)}, RecommendationMedium},
|
||||
{"low", []Candidate{mkScoredCandidate("rg1", 0.50)}, RecommendationLow},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := Recommend(g, tc.cands); got != tc.want {
|
||||
t.Errorf("%s: Recommend = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_AmbiguousRivalCapsAtMedium(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A different release group within the margin → ambiguous, even
|
||||
// though the top score alone reads strong.
|
||||
cands := []Candidate{
|
||||
mkScoredCandidate("rg1", 0.95),
|
||||
mkScoredCandidate("rg2", 0.93),
|
||||
}
|
||||
|
||||
if got := Recommend(fullGroup(), cands); got != RecommendationMedium {
|
||||
t.Errorf("ambiguous rival: Recommend = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_SameRGEditionsAreNotAmbiguous(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Multiple editions of the SAME release group score nearly
|
||||
// identically by construction — that's not ambiguity.
|
||||
cands := []Candidate{
|
||||
mkScoredCandidate("rg1", 0.95),
|
||||
mkScoredCandidate("rg1", 0.94),
|
||||
mkScoredCandidate("rg1", 0.93),
|
||||
}
|
||||
|
||||
if got := Recommend(fullGroup(), cands); got != RecommendationStrong {
|
||||
t.Errorf("same-RG editions: Recommend = %q, want strong", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_DistantRivalDoesNotCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cands := []Candidate{
|
||||
mkScoredCandidate("rg1", 0.95),
|
||||
mkScoredCandidate("rg2", 0.60),
|
||||
}
|
||||
|
||||
if got := Recommend(fullGroup(), cands); got != RecommendationStrong {
|
||||
t.Errorf("distant rival: Recommend = %q, want strong", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_AlignmentDefectsCapAtMedium(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
top := mkScoredCandidate("rg1", 0.95)
|
||||
top.Alignments = []TrackAlignment{
|
||||
{Status: AlignmentMatched},
|
||||
{Status: AlignmentMissing, LocalIndex: -1},
|
||||
}
|
||||
|
||||
if got := Recommend(fullGroup(), []Candidate{top}); got != RecommendationMedium {
|
||||
t.Errorf("missing track: Recommend = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_ThinEvidenceCapsAtMedium(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 2-track folder can't be auto-accept confident however well
|
||||
// it matches. (Local candidates skip evidence *scaling* but not
|
||||
// this cap — acting without review still needs corroboration.)
|
||||
g := Group{Tracks: []LocalTrack{{Title: "A"}, {Title: "B"}}}
|
||||
cands := []Candidate{mkScoredCandidate("rg1", 0.96)}
|
||||
|
||||
if got := Recommend(g, cands); got != RecommendationMedium {
|
||||
t.Errorf("thin evidence: Recommend = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_LocalCandidatesWithoutRGMBIDCompareByTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Local candidates may lack RG MBIDs; same title+artist means
|
||||
// same release group for ambiguity purposes.
|
||||
a := Candidate{Title: "Album", ArtistCredit: "Band", Score: 0.95}
|
||||
b := Candidate{Title: "Album", ArtistCredit: "Band", Score: 0.94}
|
||||
|
||||
if got := Recommend(fullGroup(), []Candidate{a, b}); got != RecommendationStrong {
|
||||
t.Errorf("same title/artist locals: Recommend = %q, want strong", got)
|
||||
}
|
||||
|
||||
c := Candidate{Title: "Different Album", ArtistCredit: "Band", Score: 0.94}
|
||||
if got := Recommend(fullGroup(), []Candidate{a, c}); got != RecommendationMedium {
|
||||
t.Errorf("different-title rival: Recommend = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
+177
-64
@@ -40,13 +40,51 @@ func NewScorer(q *sqlcgen.Queries, mb MBClient, logger *slog.Logger) *Scorer {
|
||||
}
|
||||
}
|
||||
|
||||
// ScoreGroup produces the full GroupScore for one tagging item.
|
||||
// Always runs both the local resolver (free) and the MB resolver
|
||||
// (cache-first, so repeats cost nothing). Candidates from both
|
||||
// sources are merged and ranked — the UI displays them with
|
||||
// provenance badges so the user can compare.
|
||||
// localSufficient is the local-candidate score above which a
|
||||
// local-first caller skips the MusicBrainz round-trip entirely: a
|
||||
// local candidate at or above this is a strong match (the same album
|
||||
// already tagged correctly in another library), so the MB cascade
|
||||
// wouldn't change the top pick and isn't worth a rate-limited network
|
||||
// call. Interactive scoring ignores this and always consults MB so
|
||||
// the review UI can show both sources side by side.
|
||||
const localSufficient = 0.90
|
||||
|
||||
// idSufficient is the score at which an ID-resolved candidate (built
|
||||
// from recording MBIDs already present in the local tags) makes the
|
||||
// fuzzy search cascade unnecessary — mirrors beets, where a strong
|
||||
// mb_albumid match returns immediately without a text search.
|
||||
const idSufficient = 0.90
|
||||
|
||||
// maxIDSampleTracks caps how many local recording MBIDs the ID-first
|
||||
// path looks up — three spread across the folder corroborate a
|
||||
// release without paying for a lookup per track.
|
||||
const maxIDSampleTracks = 3
|
||||
|
||||
// ScoreGroup produces the full GroupScore for one tagging item,
|
||||
// always consulting MusicBrainz (when a client is configured) so the
|
||||
// review UI can display local + MB candidates side by side with
|
||||
// provenance badges. Use this on interactive paths where the user is
|
||||
// looking at the result.
|
||||
func (s *Scorer) ScoreGroup(
|
||||
ctx context.Context, groupKey string,
|
||||
) (*GroupScore, error) {
|
||||
return s.scoreGroup(ctx, groupKey, false)
|
||||
}
|
||||
|
||||
// ScoreGroupLocalFirst is the cheap variant for background work
|
||||
// (prefetch): it scores local candidates first and skips the
|
||||
// MusicBrainz cascade when the best local candidate is already a
|
||||
// strong match (score >= localSufficient). Falls back to the full
|
||||
// MB-consulting path otherwise, so albums with no strong local match
|
||||
// still get a real score for the sidebar pill.
|
||||
func (s *Scorer) ScoreGroupLocalFirst(
|
||||
ctx context.Context, groupKey string,
|
||||
) (*GroupScore, error) {
|
||||
return s.scoreGroup(ctx, groupKey, true)
|
||||
}
|
||||
|
||||
func (s *Scorer) scoreGroup(
|
||||
ctx context.Context, groupKey string, localFirst bool,
|
||||
) (*GroupScore, error) {
|
||||
item, err := s.q.GetTaggingItem(ctx, groupKey)
|
||||
if err != nil {
|
||||
@@ -62,36 +100,153 @@ func (s *Scorer) ScoreGroup(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g := Group{
|
||||
AlbumName: item.AlbumName,
|
||||
AlbumArtist: item.AlbumArtist,
|
||||
Tracks: locals,
|
||||
}
|
||||
|
||||
localHits, err := s.local.ResolveLocal(ctx, item.AlbumName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Local-first short-circuit: pre-score the free local candidates
|
||||
// and, if one is already a strong match, skip the MB round-trip.
|
||||
var localCandidates []Candidate
|
||||
|
||||
skipMB := false
|
||||
|
||||
if localFirst && s.mb != nil {
|
||||
localCandidates = RankCandidates(g, localHits)
|
||||
skipMB = len(localCandidates) > 0 && localCandidates[0].Score >= localSufficient
|
||||
}
|
||||
|
||||
var mbHits []Candidate
|
||||
if s.mb != nil {
|
||||
mbHits, err = s.mb.ResolveMB(
|
||||
ctx,
|
||||
item.AlbumName,
|
||||
item.AlbumArtist,
|
||||
len(locals),
|
||||
guessArtistMBID(locals),
|
||||
)
|
||||
|
||||
if s.mb != nil && !skipMB {
|
||||
mbHits = s.resolveMBCandidates(ctx, g, groupKey)
|
||||
}
|
||||
|
||||
// Reuse the pre-ranked local list when we skipped MB; otherwise
|
||||
// rank the merged set.
|
||||
candidates := localCandidates
|
||||
if !skipMB {
|
||||
candidates = RankCandidates(g, append(localHits, mbHits...))
|
||||
}
|
||||
|
||||
return &GroupScore{
|
||||
GroupKey: groupKey,
|
||||
AlbumName: item.AlbumName,
|
||||
AlbumArtist: item.AlbumArtist,
|
||||
LocalTracks: locals,
|
||||
Candidates: candidates,
|
||||
Recommendation: Recommend(g, candidates),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveMBCandidates gathers MusicBrainz candidates for a group:
|
||||
// ID-first (recording MBIDs already in the tags), then the search
|
||||
// cascade when the ID path didn't produce a strong match. Failures
|
||||
// on either path degrade to fewer candidates, never to an error —
|
||||
// local candidates must still surface when MB is unreachable.
|
||||
func (s *Scorer) resolveMBCandidates(
|
||||
ctx context.Context, g Group, groupKey string,
|
||||
) []Candidate {
|
||||
var out []Candidate
|
||||
|
||||
if ids := sampleRecordingMBIDs(g.Tracks); len(ids) > 0 {
|
||||
idCands, err := s.mb.ResolveByRecordingMBIDs(ctx, ids)
|
||||
if err != nil {
|
||||
s.log.Warn(
|
||||
"MB resolve failed — returning local-only candidates",
|
||||
"group_key", groupKey,
|
||||
"err", err,
|
||||
"MB ID-first resolve failed — falling back to search",
|
||||
"group_key", groupKey, "err", err,
|
||||
)
|
||||
}
|
||||
|
||||
if len(idCands) > 0 {
|
||||
ranked := RankCandidates(g, idCands)
|
||||
if ranked[0].Score >= idSufficient {
|
||||
s.log.Info(
|
||||
"MB ID-first match — skipping search cascade",
|
||||
"group_key", groupKey, "score", ranked[0].Score,
|
||||
)
|
||||
|
||||
return idCands
|
||||
}
|
||||
|
||||
out = idCands
|
||||
}
|
||||
}
|
||||
|
||||
candidates := RankCandidates(locals, append(localHits, mbHits...))
|
||||
searchHits, err := s.mb.ResolveMB(ctx, g)
|
||||
if err != nil {
|
||||
s.log.Warn(
|
||||
"MB resolve failed — returning local-only candidates",
|
||||
"group_key", groupKey, "err", err,
|
||||
)
|
||||
|
||||
return &GroupScore{
|
||||
GroupKey: groupKey,
|
||||
LocalTracks: locals,
|
||||
Candidates: candidates,
|
||||
}, nil
|
||||
return out
|
||||
}
|
||||
|
||||
return append(out, dropDuplicateReleases(out, searchHits)...)
|
||||
}
|
||||
|
||||
// dropDuplicateReleases filters from `extra` any candidate whose
|
||||
// release MBID already appears in `have`.
|
||||
func dropDuplicateReleases(have, extra []Candidate) []Candidate {
|
||||
if len(have) == 0 {
|
||||
return extra
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(have))
|
||||
|
||||
for _, c := range have {
|
||||
if c.ReleaseMBID != "" {
|
||||
seen[c.ReleaseMBID] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(extra))
|
||||
|
||||
for _, c := range extra {
|
||||
if c.ReleaseMBID != "" && seen[c.ReleaseMBID] {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// sampleRecordingMBIDs picks up to maxIDSampleTracks distinct
|
||||
// recording MBIDs spread across the group (first, middle, last) —
|
||||
// enough to corroborate a release via voting without a lookup per
|
||||
// track.
|
||||
func sampleRecordingMBIDs(tracks []LocalTrack) []string {
|
||||
distinct := make([]string, 0, len(tracks))
|
||||
seen := make(map[string]bool, len(tracks))
|
||||
|
||||
for _, t := range tracks {
|
||||
if t.RecordingMBID == "" || seen[t.RecordingMBID] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[t.RecordingMBID] = true
|
||||
|
||||
distinct = append(distinct, t.RecordingMBID)
|
||||
}
|
||||
|
||||
if len(distinct) <= maxIDSampleTracks {
|
||||
return distinct
|
||||
}
|
||||
|
||||
return []string{
|
||||
distinct[0],
|
||||
distinct[len(distinct)/2],
|
||||
distinct[len(distinct)-1],
|
||||
}
|
||||
}
|
||||
|
||||
// LocalTracksForGroup exposes the local resolver so callers that
|
||||
@@ -104,32 +259,6 @@ func (s *Scorer) LocalTracksForGroup(
|
||||
return s.local.LocalTracksForGroup(ctx, groupKey)
|
||||
}
|
||||
|
||||
// PersistBest writes the top candidate's release MBID and score
|
||||
// onto the tagging_items row, bumping status to 'matched' when a
|
||||
// candidate exists. No-op when candidates is empty (keeps the
|
||||
// current status — likely 'pending').
|
||||
func (s *Scorer) PersistBest(
|
||||
ctx context.Context, score *GroupScore,
|
||||
) error {
|
||||
if score == nil || len(score.Candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
top := score.Candidates[0]
|
||||
|
||||
mbid := top.ReleaseMBID
|
||||
if mbid == "" {
|
||||
mbid = top.ReleaseGroupMBID
|
||||
}
|
||||
|
||||
return s.q.SetTaggingItemBestMatch(ctx, sqlcgen.SetTaggingItemBestMatchParams{
|
||||
BestMatchReleaseMbid: sql.NullString{String: mbid, Valid: mbid != ""},
|
||||
Score: sql.NullFloat64{Float64: top.Score, Valid: true},
|
||||
Status: "matched",
|
||||
GroupKey: score.GroupKey,
|
||||
})
|
||||
}
|
||||
|
||||
// PersistScore writes the top candidate's release MBID and score
|
||||
// onto the tagging_items row WITHOUT touching its status. Use
|
||||
// this from paths that want the sidebar pill / sort to reflect a
|
||||
@@ -156,19 +285,3 @@ func (s *Scorer) PersistScore(
|
||||
GroupKey: score.GroupKey,
|
||||
})
|
||||
}
|
||||
|
||||
// guessArtistMBID returns the first non-empty recording MBID-
|
||||
// derived artist hint we can find. Tracks carry recording MBIDs,
|
||||
// not artist MBIDs, but existing partial tags are often consistent
|
||||
// enough that any non-empty MBID signals "this album already has
|
||||
// some MB lineage". A follow-up in 010 can resolve actual artist
|
||||
// MBIDs via the artists table.
|
||||
func guessArtistMBID(tracks []LocalTrack) string {
|
||||
// Phase 009 doesn't wire up per-track artist MBIDs through
|
||||
// the LocalTrack struct yet — keeping the hook so the MB
|
||||
// resolver still compiles with the empty hint. Real artist
|
||||
// MBIDs flow in once 010 wires them into LocalTrack.
|
||||
_ = tracks
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -166,7 +166,179 @@ func TestScorer_LocalHitSurfacesFirst(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScorer_PersistBestWritesMatched(t *testing.T) {
|
||||
// TestScorer_LocalFirstSkipsMB asserts the background (prefetch)
|
||||
// scoring path makes zero MusicBrainz calls when a local candidate is
|
||||
// already a strong match — the whole point of ScoreGroupLocalFirst.
|
||||
func TestScorer_LocalFirstSkipsMB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
// A canonical local album (MBIDs present) that the pending group
|
||||
// matches track-for-track — the local candidate scores >= 0.90.
|
||||
seed(t, db, seededAlbum{
|
||||
groupKey: "g-canonical",
|
||||
albumName: "Good Album",
|
||||
releaseMBID: "rg-abcd",
|
||||
tracks: []seededTrack{
|
||||
{
|
||||
filePath: "/lib/a.mp3", title: "Song A",
|
||||
trackNumber: 1, lengthMillis: 200000, recordingMBID: "rec-a",
|
||||
},
|
||||
{
|
||||
filePath: "/lib/b.mp3", title: "Song B",
|
||||
trackNumber: 2, lengthMillis: 180000, recordingMBID: "rec-b",
|
||||
},
|
||||
},
|
||||
})
|
||||
seed(t, db, seededAlbum{
|
||||
groupKey: "g-pending",
|
||||
albumName: "Good Album",
|
||||
tracks: []seededTrack{
|
||||
{filePath: "/other/a.mp3", title: "Song A", trackNumber: 1, lengthMillis: 200000},
|
||||
{filePath: "/other/b.mp3", title: "Song B", trackNumber: 2, lengthMillis: 180000},
|
||||
},
|
||||
})
|
||||
|
||||
mbCalls := 0
|
||||
fakeMB := &countingMBClient{onSearch: func() { mbCalls++ }}
|
||||
|
||||
scorer := autotag.NewScorer(db.Queries, fakeMB, slog.New(slog.DiscardHandler))
|
||||
|
||||
result, err := scorer.ScoreGroupLocalFirst(context.Background(), "g-pending")
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreGroupLocalFirst: %v", err)
|
||||
}
|
||||
|
||||
if mbCalls != 0 {
|
||||
t.Errorf("made %d MB calls, want 0 (strong local match should skip MB)", mbCalls)
|
||||
}
|
||||
|
||||
if len(result.Candidates) == 0 || result.Candidates[0].ReleaseGroupMBID != "rg-abcd" {
|
||||
t.Errorf("top candidate = %+v, want local rg-abcd", result.Candidates)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScorer_IDFirstSkipsSearch asserts that a group whose tracks
|
||||
// already carry recording MBIDs resolves through the ID-first path
|
||||
// — the release the recordings vote for is looked up directly and
|
||||
// the fuzzy search cascade never runs.
|
||||
func TestScorer_IDFirstSkipsSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
seed(t, db, seededAlbum{
|
||||
groupKey: "g-tagged",
|
||||
albumName: "Good Album",
|
||||
tracks: []seededTrack{
|
||||
{
|
||||
filePath: "/lib/a.mp3", title: "Song A",
|
||||
trackNumber: 1, lengthMillis: 200000, recordingMBID: "rec-a",
|
||||
},
|
||||
{
|
||||
filePath: "/lib/b.mp3", title: "Song B",
|
||||
trackNumber: 2, lengthMillis: 180000, recordingMBID: "rec-b",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fake := &idFakeClient{
|
||||
recRels: map[string][]autotag.MBReleaseRef{
|
||||
"rec-a": {{MBID: "rel-full", Status: "Official", Date: "1999"}},
|
||||
"rec-b": {{MBID: "rel-full", Status: "Official", Date: "1999"}},
|
||||
},
|
||||
releases: map[string]autotag.MBRelease{
|
||||
"rel-full": {
|
||||
MBID: "rel-full", Title: "Good Album",
|
||||
ArtistCredit: "Test Artist", Status: "Official", Country: "US",
|
||||
Tracks: []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "Song A", LengthMillis: 200000, MBID: "rec-a"},
|
||||
{Position: 2, Title: "Song B", LengthMillis: 180000, MBID: "rec-b"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
scorer := autotag.NewScorer(db.Queries, fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
result, err := scorer.ScoreGroup(context.Background(), "g-tagged")
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreGroup: %v", err)
|
||||
}
|
||||
|
||||
if fake.searches != 0 {
|
||||
t.Errorf("made %d search calls, want 0 (ID-first should skip the cascade)", fake.searches)
|
||||
}
|
||||
|
||||
if len(result.Candidates) == 0 {
|
||||
t.Fatal("no candidates")
|
||||
}
|
||||
|
||||
top := result.Candidates[0]
|
||||
if top.ReleaseMBID != "rel-full" || top.Provenance != "id" {
|
||||
t.Errorf(
|
||||
"top = %q via %q (%.3f), want 'rel-full' via 'id'",
|
||||
top.ReleaseMBID, top.Provenance, top.Score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// idFakeClient serves canned recording→release lookups and counts
|
||||
// search calls so the ID-first test can assert the cascade stayed
|
||||
// cold.
|
||||
type idFakeClient struct {
|
||||
searches int
|
||||
recRels map[string][]autotag.MBReleaseRef
|
||||
releases map[string]autotag.MBRelease
|
||||
}
|
||||
|
||||
func (c *idFakeClient) SearchReleaseGroups(
|
||||
_ context.Context, _ string, _ int,
|
||||
) ([]autotag.MBReleaseGroupHit, int, error) {
|
||||
c.searches++
|
||||
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (c *idFakeClient) SearchRecordings(
|
||||
_ context.Context, _ string, _ int,
|
||||
) ([]autotag.MBRecordingHit, int, error) {
|
||||
c.searches++
|
||||
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (c *idFakeClient) LookupRecordingReleases(
|
||||
_ context.Context, mbid string,
|
||||
) ([]autotag.MBReleaseRef, error) {
|
||||
return c.recRels[mbid], nil
|
||||
}
|
||||
|
||||
func (c *idFakeClient) BrowseReleases(
|
||||
_ context.Context, _ string,
|
||||
) ([]autotag.MBRelease, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *idFakeClient) LookupRelease(
|
||||
_ context.Context, mbid string,
|
||||
) (autotag.MBRelease, error) {
|
||||
rel, ok := c.releases[mbid]
|
||||
if !ok {
|
||||
return autotag.MBRelease{}, autotag.ErrGroupNotFound
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func (c *idFakeClient) LookupReleaseGroup(
|
||||
_ context.Context, _ string,
|
||||
) (autotag.MBReleaseGroupHit, error) {
|
||||
return autotag.MBReleaseGroupHit{}, nil
|
||||
}
|
||||
|
||||
func TestScorer_PersistScoreWritesTopMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
@@ -200,8 +372,8 @@ func TestScorer_PersistBestWritesMatched(t *testing.T) {
|
||||
t.Fatalf("ScoreGroup: %v", err)
|
||||
}
|
||||
|
||||
if err := scorer.PersistBest(context.Background(), result); err != nil {
|
||||
t.Fatalf("PersistBest: %v", err)
|
||||
if err := scorer.PersistScore(context.Background(), result); err != nil {
|
||||
t.Fatalf("PersistScore: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.Queries.GetTaggingItem(context.Background(), "g-pending")
|
||||
@@ -209,8 +381,10 @@ func TestScorer_PersistBestWritesMatched(t *testing.T) {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
if got.Status != "matched" {
|
||||
t.Errorf("status = %q, want 'matched'", got.Status)
|
||||
// PersistScore records the pill score + best match but must leave
|
||||
// the review status untouched so the folder stays in the queue.
|
||||
if got.Status != "pending" {
|
||||
t.Errorf("status = %q, want 'pending' (PersistScore must not flip status)", got.Status)
|
||||
}
|
||||
|
||||
if !got.BestMatchReleaseMbid.Valid || got.BestMatchReleaseMbid.String != "rg-abcd" {
|
||||
@@ -258,10 +432,20 @@ func (c *countingMBClient) BrowseReleases(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *countingMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
|
||||
func (c *countingMBClient) SearchRecordings(
|
||||
_ context.Context, _ string, _ int,
|
||||
) ([]autotag.MBRecordingHit, int, error) {
|
||||
c.onSearch()
|
||||
|
||||
return "", nil
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (c *countingMBClient) LookupRecordingReleases(
|
||||
_ context.Context, _ string,
|
||||
) ([]autotag.MBReleaseRef, error) {
|
||||
c.onSearch()
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *countingMBClient) LookupRelease(_ context.Context, _ string) (autotag.MBRelease, error) {
|
||||
|
||||
@@ -14,6 +14,15 @@ type LocalTrack struct {
|
||||
RecordingMBID string
|
||||
}
|
||||
|
||||
// Group is the folder-level context candidates are ranked against:
|
||||
// the tagging item's album name/artist plus its local tracks. The
|
||||
// album fields are soft signals — empty values never penalize.
|
||||
type Group struct {
|
||||
AlbumName string
|
||||
AlbumArtist string
|
||||
Tracks []LocalTrack
|
||||
}
|
||||
|
||||
// CandidateSource distinguishes candidates served from the local
|
||||
// release_groups cache (zero network cost) from those fetched live.
|
||||
type CandidateSource string
|
||||
@@ -42,22 +51,26 @@ type Candidate struct {
|
||||
OriginalDate string // "YYYY" or "YYYY-MM-DD" — release group's first release
|
||||
Country string
|
||||
Status string // "Official", "Promotion", ...
|
||||
PrimaryType string // release group's primary type: "Album", "Single", ...
|
||||
TrackCount int
|
||||
Tracks []CandidateTrack
|
||||
Alignments []TrackAlignment
|
||||
Score float64 // 0..1, higher is better
|
||||
Breakdown ScoreBreakdown
|
||||
Source CandidateSource
|
||||
Provenance string // cascade step that produced this ("strict", "fuzzy-title", "paste", "local")
|
||||
Provenance string // cascade step that produced this ("strict", "fuzzy-title", "id", "paste", "local")
|
||||
}
|
||||
|
||||
// ScoreBreakdown exposes the four inputs that go into Candidate.Score
|
||||
// so the review UI can explain the ranking to the user.
|
||||
// ScoreBreakdown exposes the inputs that go into Candidate.Score so
|
||||
// the review UI can explain the ranking to the user.
|
||||
type ScoreBreakdown struct {
|
||||
TitleAvg float64 // average per-track title similarity (0..1)
|
||||
LengthAvg float64 // average per-track length similarity (0..1)
|
||||
ArtistFit float64 // album-artist vs candidate artist-credit similarity (0..1)
|
||||
AlbumFit float64 // folder album name vs candidate release title similarity (0..1)
|
||||
TrackCountFit float64 // 1.0 when local and candidate track counts match
|
||||
ReleaseMeta float64 // year + official + country, averaged
|
||||
ReleaseMeta float64 // official + country + release-group type, averaged
|
||||
Evidence float64 // confidence scale from corroborating track count (0..1)
|
||||
}
|
||||
|
||||
// CandidateTrack is one track inside a candidate release.
|
||||
@@ -97,14 +110,19 @@ type TrackAlignment struct {
|
||||
CandidateLength int64
|
||||
|
||||
TitleScore float64 // 0..1 from normalized-title edit distance
|
||||
LengthScore float64 // 0..1 from lengthScore; 0.5 (neutral) if either side unknown
|
||||
LengthDeltaMs int64 // abs(local - candidate); 0 if either side missing
|
||||
TrackNumberOK bool // local track number matches candidate position
|
||||
IDMatch bool // local recording MBID equals candidate track MBID
|
||||
Status AlignmentStatus
|
||||
}
|
||||
|
||||
// GroupScore is the scorer's full output for one tagging group.
|
||||
type GroupScore struct {
|
||||
GroupKey string
|
||||
LocalTracks []LocalTrack
|
||||
Candidates []Candidate // sorted by Score, descending
|
||||
GroupKey string
|
||||
AlbumName string
|
||||
AlbumArtist string
|
||||
LocalTracks []LocalTrack
|
||||
Candidates []Candidate // sorted by Score, descending
|
||||
Recommendation Recommendation
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ package autotagservice
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -23,6 +26,7 @@ import (
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/explore"
|
||||
"yellowjacket/backend/metadata"
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
@@ -81,6 +85,13 @@ type Service struct {
|
||||
exp *explore.Service
|
||||
logger *slog.Logger
|
||||
ctx context.Context
|
||||
// ctxReady reports whether ctx is the Wails lifecycle context set
|
||||
// via SetContext (rather than the context.Background() default). It
|
||||
// gates event emission: calling wailsruntime.EventsEmit with a
|
||||
// non-runtime context triggers log.Fatalf (os.Exit) inside Wails, so
|
||||
// a background worker that fires before OnStartup wires the context
|
||||
// would otherwise take the whole app down on launch.
|
||||
ctxReady bool
|
||||
|
||||
// Queue cursor — the group_key of the last item returned.
|
||||
// GetNextPending uses it to advance. Reset by StartAutotagQueue.
|
||||
@@ -88,12 +99,6 @@ type Service struct {
|
||||
cursor string
|
||||
libraryID int64
|
||||
|
||||
// Candidate cache: freezes the candidate list the user saw so
|
||||
// Apply operates on the exact release they selected even if a
|
||||
// racing rescore would have changed the ranking. Keyed by
|
||||
// group_key; replaced by GetCandidates / GetCandidatesForPasteURL.
|
||||
candidateCache map[string][]autotag.Candidate
|
||||
|
||||
// In-flight set for ApplyAsync. Protects against the user
|
||||
// firing two Apply jobs on the same group while the first is
|
||||
// still running. Keyed by group_key; entries removed when the
|
||||
@@ -194,7 +199,6 @@ func NewService(
|
||||
exp: exp,
|
||||
logger: logger,
|
||||
ctx: context.Background(),
|
||||
candidateCache: make(map[string][]autotag.Candidate),
|
||||
runningApplies: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
@@ -202,7 +206,36 @@ func NewService(
|
||||
// SetContext stores the Wails runtime context (called from
|
||||
// OnStartup).
|
||||
func (s *Service) SetContext(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.ctx = ctx
|
||||
s.ctxReady = ctx != nil
|
||||
}
|
||||
|
||||
// emitEvent emits a Wails runtime event, but only when the stored
|
||||
// context actually carries the Wails runtime. Wails' EventsEmit calls
|
||||
// log.Fatalf — which os.Exit()s the process and cannot be recovered —
|
||||
// whenever the context lacks its internal "events" value (e.g. the
|
||||
// context.Background() default, or any non-lifecycle context). A
|
||||
// background worker (the prefetch/apply sweeps) that emits before, or
|
||||
// independently of, OnStartup wiring the real context would otherwise
|
||||
// take the whole app down on launch. We replicate Wails' own
|
||||
// precondition here so a not-yet-ready context degrades to a no-op
|
||||
// instead of a crash.
|
||||
func (s *Service) emitEvent(eventName string, data any) {
|
||||
s.mu.Lock()
|
||||
ready := s.ctxReady
|
||||
ctx := s.ctx
|
||||
s.mu.Unlock()
|
||||
|
||||
// hasWailsRuntime mirrors the check in wails/pkg/runtime.getEvents:
|
||||
// the runtime is present only when ctx.Value("events") is non-nil.
|
||||
if !ready || ctx == nil || ctx.Value("events") == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wailsruntime.EventsEmit(ctx, eventName, data)
|
||||
}
|
||||
|
||||
// StartBackgroundPrefetch kicks off (or restarts) the prefetch
|
||||
@@ -335,7 +368,7 @@ func (s *Service) startPrefetch(libraryID int64) {
|
||||
// folder, which populates the cache via the foreground
|
||||
// GetCandidates path. Skip the redundant re-score.
|
||||
if cached := s.lookupCachedCandidates(key); len(cached) > 0 {
|
||||
wailsruntime.EventsEmit(s.ctx, events.AutotagPrefetchProgress, map[string]any{
|
||||
s.emitEvent(events.AutotagPrefetchProgress, map[string]any{
|
||||
"processed": i + 1,
|
||||
"total": total,
|
||||
})
|
||||
@@ -343,7 +376,10 @@ func (s *Service) startPrefetch(libraryID int64) {
|
||||
continue
|
||||
}
|
||||
|
||||
score, err := s.scorer.ScoreGroup(ctx, key)
|
||||
// Local-first: the background sweep skips the MusicBrainz
|
||||
// cascade when a local candidate already scores well, so a
|
||||
// library with cross-library duplicates costs no network here.
|
||||
score, err := s.scorer.ScoreGroupLocalFirst(ctx, key)
|
||||
if err != nil {
|
||||
s.logger.Debug(
|
||||
"prefetch: score failed — skipping",
|
||||
@@ -360,13 +396,13 @@ func (s *Service) startPrefetch(libraryID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
wailsruntime.EventsEmit(s.ctx, events.AutotagPrefetchProgress, map[string]any{
|
||||
s.emitEvent(events.AutotagPrefetchProgress, map[string]any{
|
||||
"processed": i + 1,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
wailsruntime.EventsEmit(s.ctx, events.AutotagPrefetchFinished, map[string]any{
|
||||
s.emitEvent(events.AutotagPrefetchFinished, map[string]any{
|
||||
"processed": total,
|
||||
"total": total,
|
||||
})
|
||||
@@ -548,11 +584,164 @@ func (s *Service) GetCandidateCoverArt(
|
||||
return s.exp.GetCandidateThumbnail(releaseMBID, releaseGroupMBID)
|
||||
}
|
||||
|
||||
// GetLocalCoverArt returns a data-URI for the artwork associated
|
||||
// with the group's local files, so the review UI can show "what the
|
||||
// folder already looks like" beside the fetched candidate art.
|
||||
// It first checks each file for embedded ID3/Vorbis pictures, then
|
||||
// falls back to a sidecar cover image (cover.jpg, folder.png, …)
|
||||
// sitting in the album directory. Untagged folders carry one or the
|
||||
// other far more often than they have a DB release-group cover, so we
|
||||
// read the files directly. Returns "" when nothing is found.
|
||||
func (s *Service) GetLocalCoverArt(groupKey string) string {
|
||||
locals, err := s.scorer.LocalTracksForGroup(s.ctx, groupKey)
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"local cover art: list tracks failed",
|
||||
"group_key", groupKey, "err", err,
|
||||
)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// 1. Embedded artwork — guaranteed to travel with the track.
|
||||
for _, t := range locals {
|
||||
if t.FilePath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
meta, err := metadata.ExtractTags(t.FilePath)
|
||||
if err != nil || meta == nil || meta.Picture == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(meta.Picture.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
mime := meta.Picture.MIMEType
|
||||
if mime == "" {
|
||||
mime = "image/jpeg"
|
||||
}
|
||||
|
||||
return "data:" + mime + ";base64," +
|
||||
base64.StdEncoding.EncodeToString(meta.Picture.Data)
|
||||
}
|
||||
|
||||
// 2. Sidecar cover image file in the album directory. Scan the
|
||||
// distinct directories the tracks live in (usually just one).
|
||||
seen := make(map[string]struct{}, 1)
|
||||
|
||||
for _, t := range locals {
|
||||
if t.FilePath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dir := filepath.Dir(t.FilePath)
|
||||
if _, ok := seen[dir]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[dir] = struct{}{}
|
||||
|
||||
if uri := folderCoverDataURI(dir); uri != "" {
|
||||
return uri
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// coverImageExts maps recognised sidecar cover-image extensions to
|
||||
// their MIME type for the data-URI.
|
||||
var coverImageExts = map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
// preferredCoverStems lists the base filenames (lower-cased, minus
|
||||
// extension) commonly used for album artwork, in priority order.
|
||||
var preferredCoverStems = []string{
|
||||
"cover", "folder", "front", "album", "albumart", "albumartsmall", "thumb",
|
||||
}
|
||||
|
||||
// folderCoverDataURI looks for a conventional cover-image file in dir
|
||||
// and returns it as a base64 data-URI, or "" when none is found.
|
||||
// Preferred filenames (cover.*, folder.*, …) win; any other image
|
||||
// file in the directory is used as a last resort. Matching is
|
||||
// case-insensitive so COVER.JPG and Folder.Png both hit.
|
||||
func folderCoverDataURI(dir string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
byStem := make(map[string]string) // lower stem -> filename
|
||||
|
||||
var fallback string
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := e.Name()
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
|
||||
if _, ok := coverImageExts[ext]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
stem := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||
if _, dup := byStem[stem]; !dup {
|
||||
byStem[stem] = name
|
||||
}
|
||||
|
||||
if fallback == "" {
|
||||
fallback = name
|
||||
}
|
||||
}
|
||||
|
||||
name := fallback
|
||||
|
||||
for _, stem := range preferredCoverStems {
|
||||
if match, ok := byStem[stem]; ok {
|
||||
name = match
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil || len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
mime := coverImageExts[strings.ToLower(filepath.Ext(name))]
|
||||
if mime == "" {
|
||||
mime = "image/jpeg"
|
||||
}
|
||||
|
||||
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// ScoreView is the Wails-friendly projection of autotag.GroupScore.
|
||||
type ScoreView struct {
|
||||
GroupKey string `json:"groupKey"`
|
||||
LocalTracks []LocalTrackView `json:"localTracks"`
|
||||
Candidates []CandidateView `json:"candidates"`
|
||||
// Recommendation is the qualitative confidence tier for the
|
||||
// ranked list: "none", "low", "medium", or "strong". Unlike the
|
||||
// raw score it accounts for ambiguity (a rival release group
|
||||
// scoring nearly as high) and alignment defects.
|
||||
Recommendation string `json:"recommendation"`
|
||||
}
|
||||
|
||||
// LocalTrackView mirrors autotag.LocalTrack.
|
||||
@@ -583,6 +772,7 @@ type CandidateView struct {
|
||||
OriginalDate string `json:"originalDate"`
|
||||
Country string `json:"country"`
|
||||
Status string `json:"status"`
|
||||
PrimaryType string `json:"primaryType"`
|
||||
TrackCount int `json:"trackCount"`
|
||||
Score float64 `json:"score"`
|
||||
Breakdown ScoreBreakdownView `json:"breakdown"`
|
||||
@@ -597,8 +787,11 @@ type CandidateView struct {
|
||||
type ScoreBreakdownView struct {
|
||||
TitleAvg float64 `json:"titleAvg"`
|
||||
LengthAvg float64 `json:"lengthAvg"`
|
||||
ArtistFit float64 `json:"artistFit"`
|
||||
AlbumFit float64 `json:"albumFit"`
|
||||
TrackCountFit float64 `json:"trackCountFit"`
|
||||
ReleaseMeta float64 `json:"releaseMeta"`
|
||||
Evidence float64 `json:"evidence"`
|
||||
}
|
||||
|
||||
// AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1
|
||||
@@ -676,21 +869,50 @@ func (s *Service) GetCandidates(groupKey string) (*ScoreView, error) {
|
||||
return scoreToView(score, s.exp), nil
|
||||
}
|
||||
|
||||
// cacheCandidates replaces the candidate list for the given group.
|
||||
// Thread-safe.
|
||||
// cacheCandidates durably persists the scored candidate list for a
|
||||
// group as a JSON blob in tagging_candidates. This freezes the exact
|
||||
// list the user saw (so Apply operates on the release they selected
|
||||
// even if a racing rescore would reorder it) AND survives process
|
||||
// restarts, so a later session reuses the result instead of re-hitting
|
||||
// MusicBrainz. Errors are logged, not returned — a failed persist
|
||||
// only costs a recompute, never correctness.
|
||||
func (s *Service) cacheCandidates(groupKey string, cands []autotag.Candidate) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
blob, err := json.Marshal(cands)
|
||||
if err != nil {
|
||||
s.logger.Warn("cache candidates: marshal failed", "group_key", groupKey, "err", err)
|
||||
|
||||
s.candidateCache[groupKey] = cands
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.Queries.UpsertTaggingCandidates(s.ctx, sqlcgen.UpsertTaggingCandidatesParams{
|
||||
GroupKey: groupKey,
|
||||
Candidates: string(blob),
|
||||
}); err != nil {
|
||||
s.logger.Warn("cache candidates: persist failed", "group_key", groupKey, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// lookupCachedCandidates returns the cached list, or empty if none.
|
||||
// lookupCachedCandidates returns the durably-stored candidate list for
|
||||
// a group, or nil when none has been computed yet (or the blob can't
|
||||
// be decoded — treated as a miss so the caller recomputes).
|
||||
func (s *Service) lookupCachedCandidates(groupKey string) []autotag.Candidate {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
blob, err := s.db.Queries.GetTaggingCandidates(s.ctx, groupKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) && !isNoRows(err) {
|
||||
s.logger.Warn("lookup candidates: query failed", "group_key", groupKey, "err", err)
|
||||
}
|
||||
|
||||
return s.candidateCache[groupKey]
|
||||
return nil
|
||||
}
|
||||
|
||||
var cands []autotag.Candidate
|
||||
if err := json.Unmarshal([]byte(blob), &cands); err != nil {
|
||||
s.logger.Warn("lookup candidates: unmarshal failed", "group_key", groupKey, "err", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return cands
|
||||
}
|
||||
|
||||
// ApplyResultView mirrors autotag.ApplyResult for the frontend.
|
||||
@@ -713,33 +935,9 @@ type FailureView struct {
|
||||
// always applies the exact release they saw in the UI.
|
||||
// Empty releaseMBID picks the top-scored candidate.
|
||||
func (s *Service) Apply(groupKey, releaseMBID string) (*ApplyResultView, error) {
|
||||
cands := s.lookupCachedCandidates(groupKey)
|
||||
if len(cands) == 0 {
|
||||
// Cache miss (user reloaded the page?): re-score and carry
|
||||
// on — worst case they get the current top-ranked release.
|
||||
score, err := s.scorer.ScoreGroup(s.ctx, groupKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("score before apply: %w", err)
|
||||
}
|
||||
|
||||
cands = score.Candidates
|
||||
s.cacheCandidates(groupKey, cands)
|
||||
}
|
||||
|
||||
locals, err := s.scorer.LocalTracksForGroup(s.ctx, groupKey)
|
||||
plan, err := s.prepareApplyPlan(groupKey, releaseMBID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load locals: %w", err)
|
||||
}
|
||||
|
||||
groupScore := &autotag.GroupScore{
|
||||
GroupKey: groupKey,
|
||||
LocalTracks: locals,
|
||||
Candidates: cands,
|
||||
}
|
||||
|
||||
plan, err := s.applier.BuildPlan(groupScore, releaseMBID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build plan: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.applier.Apply(s.ctx, plan, nil)
|
||||
@@ -799,7 +997,7 @@ func (s *Service) ApplyAsync(groupKey, releaseMBID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
wailsruntime.EventsEmit(s.ctx, events.AutotagApplyStarted, map[string]any{
|
||||
s.emitEvent(events.AutotagApplyStarted, map[string]any{
|
||||
"groupKey": groupKey,
|
||||
"total": total,
|
||||
})
|
||||
@@ -852,7 +1050,7 @@ func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int)
|
||||
defer s.endApply(groupKey)
|
||||
|
||||
onProgress := func(current, total, succeeded, failed int) {
|
||||
wailsruntime.EventsEmit(s.ctx, events.AutotagApplyProgress, map[string]any{
|
||||
s.emitEvent(events.AutotagApplyProgress, map[string]any{
|
||||
"groupKey": groupKey,
|
||||
"current": current,
|
||||
"total": total,
|
||||
@@ -879,7 +1077,7 @@ func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int)
|
||||
finished["error"] = err.Error()
|
||||
}
|
||||
|
||||
wailsruntime.EventsEmit(s.ctx, events.AutotagApplyFinished, finished)
|
||||
s.emitEvent(events.AutotagApplyFinished, finished)
|
||||
}
|
||||
|
||||
// tryStartApply records that an Apply for the given group is
|
||||
@@ -939,8 +1137,14 @@ func (s *Service) LeaveAsIs(groupKey string) error {
|
||||
}
|
||||
|
||||
// RetagGroup flips a group back to 'pending' so the user can
|
||||
// re-review after an apply or skip.
|
||||
// re-review after an apply or skip. Drops the durably-cached
|
||||
// candidate list too, so the next open recomputes against fresh
|
||||
// MusicBrainz data rather than reusing the stale stored result.
|
||||
func (s *Service) RetagGroup(groupKey string) error {
|
||||
if err := s.db.Queries.DeleteTaggingCandidates(s.ctx, groupKey); err != nil {
|
||||
s.logger.Warn("retag: clear cached candidates failed", "group_key", groupKey, "err", err)
|
||||
}
|
||||
|
||||
return s.db.Queries.SetTaggingItemStatus(
|
||||
s.ctx,
|
||||
sqlcgen.SetTaggingItemStatusParams{Status: "pending", GroupKey: groupKey},
|
||||
@@ -980,7 +1184,11 @@ func (s *Service) GetCandidatesForPasteURL(
|
||||
// splice it to the top of the list. RankCandidates would
|
||||
// re-sort by score; we explicitly keep the pasted candidate
|
||||
// first so the user's manual pick is visually anchored.
|
||||
scored := autotag.ScoreCandidate(score.LocalTracks, pasted, len(score.LocalTracks))
|
||||
scored := autotag.ScoreCandidate(autotag.Group{
|
||||
AlbumName: score.AlbumName,
|
||||
AlbumArtist: score.AlbumArtist,
|
||||
Tracks: score.LocalTracks,
|
||||
}, pasted)
|
||||
merged := append([]autotag.Candidate{scored}, score.Candidates...)
|
||||
score.Candidates = merged
|
||||
|
||||
@@ -989,6 +1197,122 @@ func (s *Service) GetCandidatesForPasteURL(
|
||||
return scoreToView(score, s.exp), nil
|
||||
}
|
||||
|
||||
// SearchHitView is one in-app MusicBrainz search result surfaced to
|
||||
// the review UI. Kind is "releasegroup" or "recording" so the
|
||||
// frontend knows which resolve path SelectSearchCandidate must take.
|
||||
type SearchHitView struct {
|
||||
MBID string `json:"mbid"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// SearchCandidates runs an in-app MusicBrainz search — the "suggest a
|
||||
// new candidate" escape hatch when the automatic cascade misses.
|
||||
// kind is "recording" (title + artist, for singletons) or anything
|
||||
// else (album + artist release-group search). Returns lightweight
|
||||
// hits; SelectSearchCandidate resolves the picked one into a scored
|
||||
// candidate.
|
||||
func (s *Service) SearchCandidates(
|
||||
kind, query, artist string,
|
||||
) ([]SearchHitView, error) {
|
||||
if kind == "recording" {
|
||||
hits, err := s.mbr.SearchRecordingHits(s.ctx, query, artist)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search recordings: %w", err)
|
||||
}
|
||||
|
||||
out := make([]SearchHitView, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
out = append(out, SearchHitView{
|
||||
MBID: h.MBID,
|
||||
Kind: "recording",
|
||||
Title: h.Title,
|
||||
Artist: h.ArtistCredit,
|
||||
Detail: formatMillis(h.LengthMillis),
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
hits, err := s.mbr.SearchReleaseGroupHits(s.ctx, query, artist)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search release groups: %w", err)
|
||||
}
|
||||
|
||||
out := make([]SearchHitView, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
detail := h.PrimaryType
|
||||
if y := h.FirstDate; len(y) >= 4 { //nolint:mnd
|
||||
detail = strings.TrimSpace(y[:4] + " " + detail)
|
||||
}
|
||||
|
||||
out = append(out, SearchHitView{
|
||||
MBID: h.MBID,
|
||||
Kind: "releasegroup",
|
||||
Title: h.Title,
|
||||
Artist: h.ArtistCredit,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SelectSearchCandidate resolves a picked search hit into a fully-
|
||||
// scored candidate and splices it to the top of the group's candidate
|
||||
// list — the same shape as the paste-URL path, so Apply works
|
||||
// unchanged. A "recording" hit is resolved to a representative
|
||||
// release first.
|
||||
func (s *Service) SelectSearchCandidate(
|
||||
groupKey, kind, mbid string,
|
||||
) (*ScoreView, error) {
|
||||
score, err := s.scorer.ScoreGroup(s.ctx, groupKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("score group: %w", err)
|
||||
}
|
||||
|
||||
var picked autotag.Candidate
|
||||
|
||||
if kind == "recording" {
|
||||
picked, err = s.mbr.ResolveOneRecordingMBID(s.ctx, mbid)
|
||||
} else {
|
||||
picked, err = s.mbr.ResolveOneReleaseMBID(s.ctx, mbid)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve %s %s: %w", kind, mbid, err)
|
||||
}
|
||||
|
||||
scored := autotag.ScoreCandidate(autotag.Group{
|
||||
AlbumName: score.AlbumName,
|
||||
AlbumArtist: score.AlbumArtist,
|
||||
Tracks: score.LocalTracks,
|
||||
}, picked)
|
||||
merged := append([]autotag.Candidate{scored}, score.Candidates...)
|
||||
score.Candidates = merged
|
||||
|
||||
s.cacheCandidates(groupKey, merged)
|
||||
|
||||
return scoreToView(score, s.exp), nil
|
||||
}
|
||||
|
||||
// formatMillis renders a millisecond duration as m:ss for the search
|
||||
// result detail line; empty for unknown lengths.
|
||||
func formatMillis(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sec := ms / 1000
|
||||
minutes := sec / 60
|
||||
seconds := sec % 60
|
||||
|
||||
return fmt.Sprintf("%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
var errInvalidMBURL = errors.New("autotag: not a valid MusicBrainz release URL")
|
||||
|
||||
// extractReleaseMBID pulls the UUID out of a MusicBrainz release
|
||||
@@ -1033,7 +1357,16 @@ func extractReleaseMBID(url string) string {
|
||||
// top-ranked candidate; pass nil to skip cover art entirely (used
|
||||
// only by paths that don't need art).
|
||||
func scoreToView(s *autotag.GroupScore, exp *explore.Service) *ScoreView {
|
||||
out := &ScoreView{GroupKey: s.GroupKey}
|
||||
rec := s.Recommendation
|
||||
if rec == "" {
|
||||
// Paths that rebuild a GroupScore from cached candidates
|
||||
// don't run the scorer; derive the tier here.
|
||||
rec = autotag.Recommend(
|
||||
autotag.Group{Tracks: s.LocalTracks}, s.Candidates,
|
||||
)
|
||||
}
|
||||
|
||||
out := &ScoreView{GroupKey: s.GroupKey, Recommendation: string(rec)}
|
||||
|
||||
for _, l := range s.LocalTracks {
|
||||
out.LocalTracks = append(out.LocalTracks, LocalTrackView{
|
||||
@@ -1058,6 +1391,7 @@ func scoreToView(s *autotag.GroupScore, exp *explore.Service) *ScoreView {
|
||||
OriginalDate: c.OriginalDate,
|
||||
Country: c.Country,
|
||||
Status: c.Status,
|
||||
PrimaryType: c.PrimaryType,
|
||||
TrackCount: c.TrackCount,
|
||||
Score: c.Score,
|
||||
Source: string(c.Source),
|
||||
@@ -1065,8 +1399,11 @@ func scoreToView(s *autotag.GroupScore, exp *explore.Service) *ScoreView {
|
||||
Breakdown: ScoreBreakdownView{
|
||||
TitleAvg: c.Breakdown.TitleAvg,
|
||||
LengthAvg: c.Breakdown.LengthAvg,
|
||||
ArtistFit: c.Breakdown.ArtistFit,
|
||||
AlbumFit: c.Breakdown.AlbumFit,
|
||||
TrackCountFit: c.Breakdown.TrackCountFit,
|
||||
ReleaseMeta: c.Breakdown.ReleaseMeta,
|
||||
Evidence: c.Breakdown.Evidence,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -174,9 +174,46 @@ func (c *Config) Save() error {
|
||||
return fmt.Errorf("could not marshal config struct: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(c.filePath, confFileData, 0o644)
|
||||
// Write atomically: marshal into a temp file in the same directory,
|
||||
// then rename it over the target. os.WriteFile truncates the file
|
||||
// in place before writing, so a crash or kill mid-write (common
|
||||
// during dev restarts) can leave a truncated — often empty — config.
|
||||
// An empty TOML file loads "successfully" as all-defaults and then
|
||||
// gets re-saved as defaults, silently wiping the user's settings.
|
||||
// A temp-file + rename makes the replacement atomic: a reader always
|
||||
// sees either the previous file or the complete new one.
|
||||
tmp, err := os.CreateTemp(path.Dir(c.filePath), "config-*.toml.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not write config file (%s): %w", c.filePath, err)
|
||||
return fmt.Errorf("could not create temp config file: %w", err)
|
||||
}
|
||||
|
||||
tmpName := tmp.Name()
|
||||
|
||||
// Best-effort cleanup if we bail before the rename succeeds.
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
|
||||
if _, err := tmp.Write(confFileData); err != nil {
|
||||
_ = tmp.Close()
|
||||
|
||||
return fmt.Errorf("could not write temp config file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
|
||||
return fmt.Errorf("could not sync temp config file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("could not close temp config file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpName, 0o644); err != nil {
|
||||
return fmt.Errorf("could not set config file permissions: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpName, c.filePath); err != nil {
|
||||
return fmt.Errorf("could not replace config file (%s): %w", c.filePath, err)
|
||||
}
|
||||
|
||||
c.logger.Debug("saved config to file", "file", c.filePath)
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
// DefaultWidth is the default window width in pixels.
|
||||
DefaultWidth = 512
|
||||
// DefaultHeight is the default window height in pixels.
|
||||
DefaultHeight = 384
|
||||
// DefaultWidth is the default window width in pixels for a fresh
|
||||
// config. Kept comfortably above the minimum so a first launch
|
||||
// (or a config with no saved size) opens at a usable size rather
|
||||
// than the cramped minimum.
|
||||
DefaultWidth = 1100
|
||||
// DefaultHeight is the default window height in pixels for a fresh config.
|
||||
DefaultHeight = 720
|
||||
|
||||
// MinWidth is the smallest allowed window width in pixels. Wails
|
||||
// enforces this at runtime; it is also the floor below which a
|
||||
// reported size is treated as bogus and not persisted.
|
||||
MinWidth = 512
|
||||
// MinHeight is the smallest allowed window height in pixels.
|
||||
MinHeight = 384
|
||||
)
|
||||
|
||||
// WindowConfig holds window size preferences.
|
||||
|
||||
+602
-10
@@ -30,13 +30,45 @@ import (
|
||||
var schemas embed.FS
|
||||
|
||||
// DB wraps the SQLite database connection and queries.
|
||||
//
|
||||
// Two handles back a single database file. db is the single-writer
|
||||
// connection (MaxOpenConns 1) used for every write and every
|
||||
// transaction. readDB is a small multi-connection, query-only pool
|
||||
// used for standalone reads. Under WAL, readers run concurrently
|
||||
// with the writer, so a long background write (index build, dump
|
||||
// patch) no longer blocks interactive searches — the reason searches
|
||||
// stalled for seconds was that the file was in rollback-journal mode
|
||||
// with a single shared connection, so any writer locked out readers.
|
||||
type DB struct {
|
||||
db *sql.DB
|
||||
Ctx context.Context
|
||||
db *sql.DB
|
||||
readDB *sql.DB
|
||||
Ctx context.Context
|
||||
// Queries runs on the single-writer connection. Use it for every
|
||||
// write and for any read that must observe an uncommitted write made
|
||||
// earlier in the same logical operation.
|
||||
Queries *sqlcgen.Queries
|
||||
logger *slog.Logger
|
||||
// ReadQueries runs on the query-only WAL read pool, so standalone
|
||||
// reads proceed concurrently with a long background write instead of
|
||||
// queueing behind it on the single writer. It observes only
|
||||
// committed data. In tests (no read pool) it aliases Queries.
|
||||
ReadQueries *sqlcgen.Queries
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Data-source names. modernc.org/sqlite only honours PRAGMAs passed
|
||||
// as `_pragma=name(value)` — the mattn-style `_journal_mode=WAL`
|
||||
// form is silently ignored, which is why WAL was never actually on.
|
||||
const (
|
||||
writeDSNParams = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
|
||||
readDSNParams = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=query_only(true)&_pragma=synchronous(NORMAL)" +
|
||||
"&_pragma=cache_size(-8000)&_pragma=mmap_size(67108864)"
|
||||
// readPoolConns bounds concurrent read connections. A handful is
|
||||
// plenty for interactive search + art/lookup fan-out and keeps WAL
|
||||
// reader overhead small.
|
||||
readPoolConns = 4
|
||||
)
|
||||
|
||||
// NewDB opens the database and applies schema migrations.
|
||||
func NewDB(logger *slog.Logger) (*DB, error) {
|
||||
defer profiling.TimeOp(logger, "database.NewDB")()
|
||||
@@ -52,7 +84,7 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
||||
|
||||
logger.Debug("opening sqlite database", "filepath", sqliteDBFilePath)
|
||||
|
||||
db, err := sql.Open("sqlite", sqliteDBFilePath+"?_busy_timeout=5000&_journal_mode=WAL")
|
||||
db, err := sql.Open("sqlite", sqliteDBFilePath+writeDSNParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not connect to sqlite database: %w", err)
|
||||
}
|
||||
@@ -126,14 +158,37 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
||||
// Get generated queries
|
||||
queries := sqlcgen.New(db)
|
||||
|
||||
// Open a separate query-only read pool. The write handle above
|
||||
// has already converted the file to WAL, so these connections read
|
||||
// a consistent snapshot concurrently with in-flight writes.
|
||||
readDB, err := sql.Open("sqlite", sqliteDBFilePath+readDSNParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open read pool: %w", err)
|
||||
}
|
||||
|
||||
readDB.SetMaxOpenConns(readPoolConns)
|
||||
|
||||
return &DB{
|
||||
db: db,
|
||||
Ctx: dbCtx,
|
||||
Queries: queries,
|
||||
logger: logger,
|
||||
db: db,
|
||||
readDB: readDB,
|
||||
Ctx: dbCtx,
|
||||
Queries: queries,
|
||||
ReadQueries: sqlcgen.New(readDB),
|
||||
logger: logger,
|
||||
}, err
|
||||
}
|
||||
|
||||
// reader returns the handle standalone reads should use: the
|
||||
// query-only read pool when present, else the write handle (tests
|
||||
// share one in-memory connection, which cannot be reopened).
|
||||
func (d *DB) reader() *sql.DB {
|
||||
if d.readDB != nil {
|
||||
return d.readDB
|
||||
}
|
||||
|
||||
return d.db
|
||||
}
|
||||
|
||||
// BeginTx starts a new database transaction.
|
||||
func (d *DB) BeginTx() (*sql.Tx, error) {
|
||||
return d.db.BeginTx(d.Ctx, nil)
|
||||
@@ -144,9 +199,20 @@ func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) {
|
||||
return d.db.ExecContext(d.Ctx, query, args...)
|
||||
}
|
||||
|
||||
// QueryContext executes a query that returns rows.
|
||||
// QueryContext executes a query that returns rows. Reads run on the
|
||||
// query-only read pool so they proceed concurrently with writes under
|
||||
// WAL instead of queueing behind the single writer connection.
|
||||
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) {
|
||||
return d.db.QueryContext(d.Ctx, query, args...)
|
||||
return d.reader().QueryContext(d.Ctx, query, args...)
|
||||
}
|
||||
|
||||
// QueryContextWith executes a query that returns rows using a
|
||||
// caller-supplied context instead of the DB's lifecycle context.
|
||||
// This lets an individual query (e.g. a superseded search) be
|
||||
// cancelled independently. Like QueryContext it runs on the read
|
||||
// pool.
|
||||
func (d *DB) QueryContextWith(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
return d.reader().QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Logger returns the structured logger bound to this DB. Callers can
|
||||
@@ -976,6 +1042,468 @@ func runMigrations(
|
||||
}
|
||||
}
|
||||
|
||||
if version < 37 { //nolint:mnd
|
||||
if err := migration37ExploreFTSDiacritics(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 38 { //nolint:mnd
|
||||
if err := migration38TaggingCandidates(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 39 { //nolint:mnd
|
||||
if err := migration39LyricsIndex(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 40 { //nolint:mnd
|
||||
if err := migration40ExploreExactMatchIndexes(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 41 { //nolint:mnd
|
||||
if err := migration41ExploreChampionFTS(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 42 { //nolint:mnd
|
||||
if err := migration42ReleaseToRG(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 43 { //nolint:mnd
|
||||
if err := migration43MergeArtistCredits(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 44 { //nolint:mnd
|
||||
if err := migration44ExploreCAAReleaseIndex(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 45 { //nolint:mnd
|
||||
if err := migration45Analyze(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if version < 46 { //nolint:mnd
|
||||
if err := migration46SmartSnapshot(ctx, db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration46SmartSnapshot adds the smart_snapshot_at column to the
|
||||
// playlists table. Smart playlists now materialize their evaluated
|
||||
// membership into playlist_tracks and only re-evaluate on demand; the
|
||||
// timestamp records when that snapshot was last taken (NULL means the
|
||||
// playlist has never been materialized, so it is backfilled on first
|
||||
// open).
|
||||
func migration46SmartSnapshot(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 46: smart playlist snapshot column")
|
||||
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`ALTER TABLE playlists
|
||||
ADD COLUMN smart_snapshot_at DATETIME`,
|
||||
); err != nil {
|
||||
if !isDuplicateColumnErr(err) {
|
||||
return fmt.Errorf(
|
||||
"migration 46: could not add smart_snapshot_at column: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
ctx, "PRAGMA user_version = 46",
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"migration 46: set user_version: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info("migration 46 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration45Analyze runs ANALYZE so SQLite's query planner has real
|
||||
// table/index statistics. Without stats the planner guesses from row
|
||||
// counts alone and mis-chose indexes on the ~2M-row explore_index — e.g.
|
||||
// the top-result parent-release lookup scanned all 400k release_group
|
||||
// rows via idx_explore_index_entity_pop instead of seeking the new
|
||||
// idx_explore_caa_release, costing seconds per search. ANALYZE populates
|
||||
// sqlite_stat1 (a one-time ~1.5s scan) and the planner then picks the
|
||||
// right index for that query and every other query on these large tables.
|
||||
func migration45Analyze(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 45: ANALYZE for query planner statistics")
|
||||
|
||||
if _, err := db.ExecContext(ctx, "ANALYZE"); err != nil {
|
||||
return fmt.Errorf("migration 45: analyze: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 45"); err != nil {
|
||||
return fmt.Errorf("migration 45: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 45 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration44ExploreCAAReleaseIndex adds a partial index on
|
||||
// caa_release_mbid so the top-result resolver's parent-release-group
|
||||
// lookup (SearchIndex.ReleaseGroupMBIDsForCAAReleaseMBIDs) seeks the
|
||||
// index instead of scanning every release_group row in explore_index
|
||||
// (~150k) on the hot search path. The index is partial, mirroring the
|
||||
// query's own filter (entity_type = 'release_group' AND
|
||||
// caa_release_mbid is non-empty), so it stays small and covers exactly the
|
||||
// rows that lookup can match. Without it, a generic query whose top
|
||||
// results include recordings with cover art (e.g. "big") spends
|
||||
// seconds in this scan.
|
||||
func migration44ExploreCAAReleaseIndex(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 44: explore caa_release_mbid index")
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
|
||||
ON explore_index(caa_release_mbid)
|
||||
WHERE entity_type = 'release_group' AND caa_release_mbid != ''
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 44: create caa_release index: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 44"); err != nil {
|
||||
return fmt.Errorf("migration 44: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 44 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration43MergeArtistCredits repairs artist rows that were created
|
||||
// from full credit strings. Before the scanner resolved a track's
|
||||
// primary artist, a credit like "Lana Del Rey ft. Sean Lennon" was
|
||||
// stored as its own artists row and stamped with the primary artist's
|
||||
// single MBID — so one MusicBrainz artist fanned out into many rows that
|
||||
// shared an MBID, and the explore index (last-write-wins per MBID) then
|
||||
// displayed a featured-credit string as the artist's name.
|
||||
//
|
||||
// This collapses every set of artists rows that share an MBID into the
|
||||
// one "clean" member (a name with no featuring clause), repoints the
|
||||
// artist_credit_artist links, deletes the redundant rows, and refreshes
|
||||
// the explore index's artist titles from the survivors. Clusters with
|
||||
// no clean member (all names carry a marker) are left untouched.
|
||||
func migration43MergeArtistCredits(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 43: merge credit-string artists")
|
||||
|
||||
// Map each redundant artist row to the clean canonical row for its
|
||||
// MBID. "Clean" = a name carrying no featuring marker; the lowest
|
||||
// id among those is the canonical survivor.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TEMP TABLE artist_merge_map AS
|
||||
SELECT a.id AS dirty_id, canon.canon_id AS canon_id
|
||||
FROM artists a
|
||||
JOIN (
|
||||
SELECT mbid, MIN(id) AS canon_id
|
||||
FROM artists
|
||||
WHERE mbid IS NOT NULL AND mbid != ''
|
||||
AND lower(name) NOT LIKE '% feat %'
|
||||
AND lower(name) NOT LIKE '% feat. %'
|
||||
AND lower(name) NOT LIKE '% featuring %'
|
||||
AND lower(name) NOT LIKE '% ft %'
|
||||
AND lower(name) NOT LIKE '% ft. %'
|
||||
GROUP BY mbid
|
||||
) canon ON canon.mbid = a.mbid
|
||||
WHERE a.id != canon.canon_id
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 43: build merge map: %w", err)
|
||||
}
|
||||
|
||||
// Drop links that would collide with an existing (canonical, credit)
|
||||
// link after repointing — the unique index would otherwise reject
|
||||
// the UPDATE.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
DELETE FROM artist_credit_artist
|
||||
WHERE id IN (
|
||||
SELECT aca.id
|
||||
FROM artist_credit_artist aca
|
||||
JOIN artist_merge_map m ON m.dirty_id = aca.artist_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM artist_credit_artist keep
|
||||
WHERE keep.artist_id = m.canon_id
|
||||
AND keep.credit_id = aca.credit_id
|
||||
)
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 43: prune colliding links: %w", err)
|
||||
}
|
||||
|
||||
// Repoint surviving links to the canonical artist.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE artist_credit_artist
|
||||
SET artist_id = (
|
||||
SELECT canon_id FROM artist_merge_map
|
||||
WHERE dirty_id = artist_credit_artist.artist_id
|
||||
)
|
||||
WHERE artist_id IN (SELECT dirty_id FROM artist_merge_map)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 43: repoint links: %w", err)
|
||||
}
|
||||
|
||||
// Remove the now-orphaned credit-string artist rows.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
DELETE FROM artists WHERE id IN (SELECT dirty_id FROM artist_merge_map)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 43: delete merged artists: %w", err)
|
||||
}
|
||||
|
||||
// Refresh explore-index artist rows from the surviving library
|
||||
// artists so their (previously clobbered) titles show the clean
|
||||
// name. The AFTER UPDATE trigger keeps explore_index_fts in sync.
|
||||
// Only rows backed by a library artist are touched; dump-only rows
|
||||
// are left alone.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE explore_index
|
||||
SET title = (
|
||||
SELECT name FROM artists
|
||||
WHERE artists.mbid = explore_index.mbid ORDER BY id LIMIT 1),
|
||||
artist_name = (
|
||||
SELECT name FROM artists
|
||||
WHERE artists.mbid = explore_index.mbid ORDER BY id LIMIT 1),
|
||||
local_artist_id = (
|
||||
SELECT id FROM artists
|
||||
WHERE artists.mbid = explore_index.mbid ORDER BY id LIMIT 1)
|
||||
WHERE entity_type = 'artist'
|
||||
AND EXISTS (SELECT 1 FROM artists WHERE artists.mbid = explore_index.mbid)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 43: refresh explore titles: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS artist_merge_map"); err != nil {
|
||||
return fmt.Errorf("migration 43: drop temp table: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 43"); err != nil {
|
||||
return fmt.Errorf("migration 43: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 43 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration42ReleaseToRG creates the release_to_rg mapping table: for
|
||||
// every release under an indexed release group, which release-group it
|
||||
// belongs to. It is populated from the canonical dump during a full
|
||||
// import (the mapping is otherwise in-memory only and discarded). The
|
||||
// incremental-dump popularity refresh uses it to roll per-release listen
|
||||
// deltas up to their release group, so album popularity stays fresh
|
||||
// without any API call.
|
||||
func migration42ReleaseToRG(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 42: release_to_rg table")
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS release_to_rg (
|
||||
release_mbid TEXT PRIMARY KEY,
|
||||
rg_mbid TEXT NOT NULL
|
||||
) WITHOUT ROWID
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 42: create release_to_rg: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx,
|
||||
"PRAGMA user_version = 42",
|
||||
); err != nil {
|
||||
return fmt.Errorf("migration 42: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 42 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration41ExploreChampionFTS creates the "champion" full-text index:
|
||||
// a second external-content FTS5 over explore_index that holds only the
|
||||
// high-popularity / owned rows. Short generic prefixes ("the", "a")
|
||||
// match hundreds of thousands of rows in the full index, and the
|
||||
// popularity-blended ORDER BY must score every one of them — seconds of
|
||||
// work. Routing those queries at the champion index instead scores only
|
||||
// the ~90k rows that could plausibly win, cutting the query from seconds
|
||||
// to tens of milliseconds. The table is created empty here; the search
|
||||
// index populates it at runtime (see SearchIndex.RebuildChampionIndex)
|
||||
// because the row set derives from popularity, which changes as the
|
||||
// index is (re)built.
|
||||
func migration41ExploreChampionFTS(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 41: explore champion FTS")
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS explore_champion_fts USING fts5(
|
||||
title, artist_name, aliases,
|
||||
content='explore_index',
|
||||
content_rowid='id',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 41: create champion fts: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 41"); err != nil {
|
||||
return fmt.Errorf("migration 41: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 41 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration39LyricsIndex creates the contentless FTS5 lyrics_index
|
||||
// (see lyrics_index.sql) and back-populates it from any recordings
|
||||
// that already have embedded lyrics, so lyric search works on
|
||||
// existing libraries without waiting for a rescan.
|
||||
func migration39LyricsIndex(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 39: lyrics_index FTS")
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_index USING fts5(
|
||||
lyrics,
|
||||
content='',
|
||||
contentless_delete=1,
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 39: create lyrics_index: %w", err)
|
||||
}
|
||||
|
||||
// Back-populate from recordings that already carry lyrics. The
|
||||
// rowid is the recording id so it stays stable across rebuilds.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO lyrics_index(rowid, lyrics)
|
||||
SELECT id, lyrics
|
||||
FROM recordings
|
||||
WHERE lyrics IS NOT NULL AND lyrics != ''
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 39: populate lyrics_index: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 39"); err != nil {
|
||||
return fmt.Errorf("migration 39: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 39 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration40ExploreExactMatchIndexes adds partial expression indexes
|
||||
// on LOWER(title) and LOWER(artist_name) so the interactive top-result
|
||||
// resolver's exact-match lookup (SearchIndex.ExactMatches) seeks the
|
||||
// index instead of scanning all ~240k explore_index rows on every
|
||||
// keystroke. The indexes are partial (WHERE popularity > 0) because
|
||||
// that lookup always filters on popularity, keeping them small; the
|
||||
// UNION-of-equalities query shape in ExactMatches is what lets SQLite
|
||||
// use them (an OR across the two columns forces a scan instead).
|
||||
func migration40ExploreExactMatchIndexes(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 40: explore exact-match indexes")
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
|
||||
ON explore_index(LOWER(title))
|
||||
WHERE popularity > 0
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 40: create title index: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
|
||||
ON explore_index(LOWER(artist_name))
|
||||
WHERE popularity > 0
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 40: create artist index: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 40"); err != nil {
|
||||
return fmt.Errorf("migration 40: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 40 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration38TaggingCandidates creates the tagging_candidates table —
|
||||
// a durable per-group store for the scored candidate list so it is
|
||||
// computed once and reused across restarts instead of re-hitting
|
||||
// MusicBrainz every session (see tagging_candidates.sql). A plain
|
||||
// CREATE TABLE IF NOT EXISTS is safe on both fresh and existing DBs.
|
||||
func migration38TaggingCandidates(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 38: tagging_candidates")
|
||||
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tagging_candidates (
|
||||
group_key TEXT PRIMARY KEY,
|
||||
candidates TEXT NOT NULL,
|
||||
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(group_key) REFERENCES tagging_items(group_key) ON DELETE CASCADE
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migration 38: create tagging_candidates: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 38"); err != nil {
|
||||
return fmt.Errorf("migration 38: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 38 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1010,6 +1538,70 @@ func migration36ClearedAt(
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration37ExploreFTSDiacritics rebuilds explore_index_fts with the
|
||||
// "unicode61 remove_diacritics 2" tokeniser so accented queries match
|
||||
// their unaccented forms (e.g. "beyonce" finds "Beyoncé"), matching the
|
||||
// library search_index tokeniser. The original table (migration 26)
|
||||
// was created with the default tokeniser, which does not fold
|
||||
// diacritics.
|
||||
//
|
||||
// Because explore_index_fts is an external-content table over
|
||||
// explore_index, the rebuild repopulates from the existing content
|
||||
// rows — no data loss and no need to re-run the expensive tiered index
|
||||
// build.
|
||||
func migration37ExploreFTSDiacritics(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info("applying migration 37: explore_index_fts diacritic folding")
|
||||
|
||||
// Drop the sync triggers and the FTS table, then recreate both.
|
||||
// The triggers must go first — they reference the FTS table.
|
||||
stmts := []string{
|
||||
`DROP TRIGGER IF EXISTS explore_index_ai`,
|
||||
`DROP TRIGGER IF EXISTS explore_index_ad`,
|
||||
`DROP TRIGGER IF EXISTS explore_index_au`,
|
||||
`DROP TABLE IF EXISTS explore_index_fts`,
|
||||
`CREATE VIRTUAL TABLE explore_index_fts USING fts5(
|
||||
title, artist_name, aliases,
|
||||
content='explore_index',
|
||||
content_rowid='id',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
)`,
|
||||
`CREATE TRIGGER explore_index_ai AFTER INSERT ON explore_index BEGIN
|
||||
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
||||
VALUES (new.id, new.title, new.artist_name, new.aliases);
|
||||
END`,
|
||||
`CREATE TRIGGER explore_index_ad AFTER DELETE ON explore_index BEGIN
|
||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||
END`,
|
||||
`CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
|
||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
||||
VALUES (new.id, new.title, new.artist_name, new.aliases);
|
||||
END`,
|
||||
// Repopulate the FTS index from the content table.
|
||||
`INSERT INTO explore_index_fts(explore_index_fts) VALUES('rebuild')`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("migration 37: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 37"); err != nil {
|
||||
return fmt.Errorf("migration 37: set user_version: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("migration 37 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfills it from file_path, creates the basename index, and
|
||||
// populates the FTS5 search_index table.
|
||||
func migration2BasenameAndFTS(
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// LyricsHit is a single result from a lyric-fragment search: the
|
||||
// matched recording plus enough metadata to render and play it.
|
||||
type LyricsHit struct {
|
||||
RecordingID int64
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
}
|
||||
|
||||
// SearchLyrics finds recordings whose lyrics match the given query,
|
||||
// ranked by FTS5 relevance. The query is treated as a phrase so a
|
||||
// fragment like "hello darkness my old friend" matches consecutive
|
||||
// words rather than each word independently. Returns nil for an
|
||||
// empty query.
|
||||
func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
ftsQuery := buildLyricsPhraseQuery(query)
|
||||
if ftsQuery == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Map the matched recording (lyrics_index.rowid == recordings.id)
|
||||
// to a representative playable file via the lowest audio_files id,
|
||||
// then to the track_metadata VIEW for display fields.
|
||||
//
|
||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
tm.file_path,
|
||||
tm.length_milliseconds,
|
||||
tm.title,
|
||||
tm.artist_name,
|
||||
tm.album
|
||||
FROM lyrics_index li
|
||||
JOIN recordings r ON r.id = li.rowid
|
||||
JOIN (
|
||||
SELECT recording_id, MIN(id) AS af_id
|
||||
FROM audio_files
|
||||
GROUP BY recording_id
|
||||
) af ON af.recording_id = r.id
|
||||
JOIN track_metadata tm ON tm.id = af.af_id
|
||||
WHERE lyrics_index MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lyrics search failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var results []LyricsHit
|
||||
|
||||
for rows.Next() {
|
||||
var h LyricsHit
|
||||
if err := rows.Scan(
|
||||
&h.RecordingID,
|
||||
&h.FilePath,
|
||||
&h.LengthMilliseconds,
|
||||
&h.Title,
|
||||
&h.Artist,
|
||||
&h.Album,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("could not scan lyrics hit: %w", err)
|
||||
}
|
||||
|
||||
results = append(results, h)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("lyrics hit iteration error: %w", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetRecordingLyrics returns the stored lyrics for a recording, or
|
||||
// an empty string if none are stored.
|
||||
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
|
||||
var lyrics string
|
||||
|
||||
err := d.db.QueryRowContext(d.Ctx,
|
||||
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?",
|
||||
recordingID,
|
||||
).Scan(&lyrics)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read recording lyrics: %w", err)
|
||||
}
|
||||
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS
|
||||
// lyrics_index in sync (delete + reinsert the single row). Used by
|
||||
// the LRCLIB backfill to persist fetched lyrics. Passing an empty
|
||||
// string clears both the column and the index entry.
|
||||
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"UPDATE recordings SET lyrics = ? WHERE id = ?",
|
||||
lyrics, recordingID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not update recording lyrics: %w", err)
|
||||
}
|
||||
|
||||
return d.upsertLyricsIndex(recordingID, lyrics)
|
||||
}
|
||||
|
||||
// upsertLyricsIndex refreshes a single recording's entry in the
|
||||
// contentless lyrics_index. contentless_delete=1 makes the DELETE
|
||||
// valid; an empty lyrics string leaves the row deleted.
|
||||
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(lyrics) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
|
||||
recordingID, lyrics,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not insert lyrics_index row: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebuildLyricsIndex repopulates lyrics_index from scratch using the
|
||||
// current recordings table. Cheap for a personal library and safe to
|
||||
// run after every scan.
|
||||
func (d *DB) RebuildLyricsIndex() error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics_index",
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not clear lyrics_index: %w", err)
|
||||
}
|
||||
|
||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input.
|
||||
if _, err := d.db.ExecContext(d.Ctx, `
|
||||
INSERT INTO lyrics_index(rowid, lyrics)
|
||||
SELECT id, lyrics
|
||||
FROM recordings
|
||||
WHERE lyrics IS NOT NULL AND lyrics != ''
|
||||
`); err != nil {
|
||||
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordingsMissingLyrics returns recordings that have no stored
|
||||
// lyrics but do carry the artist/title/duration needed to look them
|
||||
// up from an external provider. Used by the LRCLIB backfill. The
|
||||
// limit bounds each batch so the backfill can be run incrementally.
|
||||
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, ''),
|
||||
MIN(af.length_milliseconds)
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
WHERE (r.lyrics IS NULL OR r.lyrics = '')
|
||||
AND r.name IS NOT NULL AND r.name != ''
|
||||
AND ac.text IS NOT NULL AND ac.text != ''
|
||||
GROUP BY r.id
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []LyricsCandidate
|
||||
|
||||
for rows.Next() {
|
||||
var c LyricsCandidate
|
||||
if err := rows.Scan(
|
||||
&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
|
||||
}
|
||||
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("lyrics candidate iteration error: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LyricsCandidate identifies a recording that needs its lyrics fetched
|
||||
// and carries the fields an external provider matches on.
|
||||
type LyricsCandidate struct {
|
||||
RecordingID int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
LengthMilliseconds int64
|
||||
}
|
||||
|
||||
// RecordingLyricLookup returns the provider-match fields (artist,
|
||||
// title, album, duration) for a single recording, so lyrics can be
|
||||
// fetched on demand. Returns nil if the recording has no audio file
|
||||
// or no artist/title to match on.
|
||||
func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) {
|
||||
var c LyricsCandidate
|
||||
|
||||
err := d.db.QueryRowContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, ''),
|
||||
COALESCE(MIN(af.length_milliseconds), 0)
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
WHERE r.id = ?
|
||||
GROUP BY r.id
|
||||
`, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not look up recording for lyrics: %w", err)
|
||||
}
|
||||
|
||||
if c.Title == "" || c.Artist == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// buildLyricsPhraseQuery turns a user's lyric fragment into an FTS5
|
||||
// phrase query — a single double-quoted string of tokens — so the
|
||||
// words must appear adjacently ("hello darkness my old friend"),
|
||||
// which is what a lyric search means. All non-alphanumeric runes are
|
||||
// treated as separators (matching the unicode61 tokeniser), so no
|
||||
// user character can break out of the quoted phrase. Returns "" when
|
||||
// the fragment has no searchable tokens.
|
||||
func buildLyricsPhraseQuery(query string) string {
|
||||
fields := strings.FieldsFunc(query, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return `"` + strings.Join(fields, " ") + `"`
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedLyricsTrack inserts the minimal FK chain (artist_credit →
|
||||
// recording → audio_file → release_group link) for one track with the
|
||||
// given lyrics, so lyric-search tests have realistic joins.
|
||||
func seedLyricsTrack(
|
||||
t *testing.T,
|
||||
db *DB,
|
||||
id int64,
|
||||
title, artist, album, lyrics string,
|
||||
lenMs int64,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist,
|
||||
); err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release_group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)",
|
||||
id, title, id, nullableLyrics(lyrics),
|
||||
); err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id,
|
||||
); err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)",
|
||||
id, id,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release_group_recordings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func nullableLyrics(l string) any {
|
||||
if l == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
|
||||
var b []byte
|
||||
|
||||
for v > 0 {
|
||||
b = append([]byte{byte('0' + v%10)}, b...)
|
||||
v /= 10
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestSearchLyrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedLyricsTrack(
|
||||
t,
|
||||
db,
|
||||
1,
|
||||
"The Sound of Silence",
|
||||
"Simon & Garfunkel",
|
||||
"Sounds of Silence",
|
||||
"Hello darkness my old friend\nI've come to talk with you again",
|
||||
180000,
|
||||
)
|
||||
seedLyricsTrack(t, db, 2, "Bohemian Rhapsody", "Queen", "A Night at the Opera",
|
||||
"Is this the real life? Is this just fantasy?", 354000)
|
||||
seedLyricsTrack(t, db, 3, "Instrumental Track", "Some Artist", "Some Album",
|
||||
"", 200000) // no lyrics — must never appear in results
|
||||
|
||||
if err := db.RebuildLyricsIndex(); err != nil {
|
||||
t.Fatalf("RebuildLyricsIndex: %v", err)
|
||||
}
|
||||
|
||||
t.Run("phrase match returns the right track with metadata", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hits, err := db.SearchLyrics("hello darkness my old friend", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchLyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("expected 1 hit, got %d: %+v", len(hits), hits)
|
||||
}
|
||||
|
||||
h := hits[0]
|
||||
if h.RecordingID != 1 {
|
||||
t.Errorf("RecordingID = %d, want 1", h.RecordingID)
|
||||
}
|
||||
|
||||
if h.Title != "The Sound of Silence" {
|
||||
t.Errorf("Title = %q, want The Sound of Silence", h.Title)
|
||||
}
|
||||
|
||||
if h.Artist != "Simon & Garfunkel" {
|
||||
t.Errorf("Artist = %q, want Simon & Garfunkel", h.Artist)
|
||||
}
|
||||
|
||||
if h.Album != "Sounds of Silence" {
|
||||
t.Errorf("Album = %q, want Sounds of Silence", h.Album)
|
||||
}
|
||||
|
||||
if h.FilePath == "" {
|
||||
t.Error("FilePath is empty; expected a playable path")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adjacency: scrambled words do not match as a phrase", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hits, err := db.SearchLyrics("friend old darkness", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchLyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(hits) != 0 {
|
||||
t.Errorf("expected 0 phrase hits for scrambled words, got %d", len(hits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty query returns nil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hits, err := db.SearchLyrics(" ", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchLyrics: %v", err)
|
||||
}
|
||||
|
||||
if hits != nil {
|
||||
t.Errorf("expected nil for empty query, got %+v", hits)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match returns no hits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hits, err := db.SearchLyrics("this phrase appears in no song", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchLyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(hits) != 0 {
|
||||
t.Errorf("expected 0 hits, got %d", len(hits))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Track starts with no lyrics.
|
||||
seedLyricsTrack(t, db, 1, "Yesterday", "The Beatles", "Help!", "", 125000)
|
||||
|
||||
if err := db.RebuildLyricsIndex(); err != nil {
|
||||
t.Fatalf("RebuildLyricsIndex: %v", err)
|
||||
}
|
||||
|
||||
// Nothing indexed yet.
|
||||
if hits, _ := db.SearchLyrics("yesterday all my troubles", 10); len(hits) != 0 {
|
||||
t.Fatalf("expected 0 hits before backfill, got %d", len(hits))
|
||||
}
|
||||
|
||||
// Backfill lyrics — should update both the column and the FTS index.
|
||||
const lyrics = "Yesterday all my troubles seemed so far away"
|
||||
if err := db.SetRecordingLyrics(1, lyrics); err != nil {
|
||||
t.Fatalf("SetRecordingLyrics: %v", err)
|
||||
}
|
||||
|
||||
stored, err := db.GetRecordingLyrics(1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordingLyrics: %v", err)
|
||||
}
|
||||
|
||||
if stored != lyrics {
|
||||
t.Errorf("stored lyrics = %q, want %q", stored, lyrics)
|
||||
}
|
||||
|
||||
hits, err := db.SearchLyrics("all my troubles seemed so far away", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchLyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(hits) != 1 || hits[0].RecordingID != 1 {
|
||||
t.Fatalf("expected recording 1 after backfill, got %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingsMissingLyrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
|
||||
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
|
||||
|
||||
missing, err := db.RecordingsMissingLyrics(50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordingsMissingLyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("expected 1 candidate, got %d: %+v", len(missing), missing)
|
||||
}
|
||||
|
||||
c := missing[0]
|
||||
if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
|
||||
t.Errorf("unexpected candidate: %+v", c)
|
||||
}
|
||||
|
||||
if c.LengthMilliseconds != 200000 {
|
||||
t.Errorf("LengthMilliseconds = %d, want 200000", c.LengthMilliseconds)
|
||||
}
|
||||
|
||||
// Single-recording lookup mirrors the batch fields.
|
||||
one, err := db.RecordingLyricLookup(2)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordingLyricLookup: %v", err)
|
||||
}
|
||||
|
||||
if one == nil || one.Artist != "Artist B" || one.Album != "Album B" {
|
||||
t.Errorf("unexpected lookup: %+v", one)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,17 @@ SELECT
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
rg.mbid,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
@@ -89,6 +100,17 @@ SELECT
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
rg.mbid,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
@@ -116,6 +138,17 @@ SELECT
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
@@ -141,6 +174,17 @@ SELECT
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- name: UpsertTaggingCandidates :exec
|
||||
INSERT INTO tagging_candidates (group_key, candidates, computed_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(group_key) DO UPDATE SET
|
||||
candidates = excluded.candidates,
|
||||
computed_at = excluded.computed_at;
|
||||
|
||||
-- name: GetTaggingCandidates :one
|
||||
SELECT candidates FROM tagging_candidates
|
||||
WHERE group_key = ?
|
||||
LIMIT 1;
|
||||
|
||||
-- name: DeleteTaggingCandidates :exec
|
||||
DELETE FROM tagging_candidates
|
||||
WHERE group_key = ?;
|
||||
@@ -62,7 +62,7 @@ SELECT
|
||||
ti.library_id,
|
||||
COALESCE(lb.name, '') AS library_name,
|
||||
COALESCE(lb.path, '') AS library_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key AND af.group_key != '' LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
ti.track_count,
|
||||
ti.album_name,
|
||||
ti.album_artist,
|
||||
@@ -93,7 +93,7 @@ SELECT
|
||||
ti.library_id,
|
||||
COALESCE(lb.name, '') AS library_name,
|
||||
COALESCE(lb.path, '') AS library_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key AND af.group_key != '' LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
ti.track_count,
|
||||
ti.album_name,
|
||||
ti.album_artist,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Contentless FTS5 index over recording lyrics, enabling
|
||||
-- "search by a lyric fragment → find the song". The rowid is
|
||||
-- recordings.id. Only recordings with non-empty lyrics are indexed.
|
||||
--
|
||||
-- content='' means the lyric text itself is NOT stored a second time
|
||||
-- (it already lives in recordings.lyrics); the index keeps only the
|
||||
-- tokenised inverted index, so it stays compact even for large
|
||||
-- libraries. contentless_delete=1 lets us delete/reinsert a single
|
||||
-- row when a track's lyrics change (scan update or LRCLIB backfill).
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_index USING fts5(
|
||||
lyrics,
|
||||
content='',
|
||||
contentless_delete=1,
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
);
|
||||
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS playlists (
|
||||
name TEXT NOT NULL,
|
||||
is_smart INTEGER NOT NULL DEFAULT 0,
|
||||
smart_rules TEXT,
|
||||
smart_snapshot_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- tagging_candidates durably stores the scored candidate list for a
|
||||
-- tagging group so it survives process restarts. Without it, only the
|
||||
-- top score (a single REAL on tagging_items) is persisted; the full
|
||||
-- candidate list — releases, tracks, alignments — is recomputed every
|
||||
-- session, re-hitting MusicBrainz whenever the short-TTL http_cache has
|
||||
-- expired. The blob is written once when a group is first scored and
|
||||
-- read back on every subsequent open.
|
||||
--
|
||||
-- candidates holds the JSON-encoded []autotag.Candidate. ON DELETE
|
||||
-- CASCADE ties the blob's lifetime to its tagging_items row: when a
|
||||
-- group's tracks change, the scan path deletes the old group_key row
|
||||
-- (and SQLite, with foreign_keys = ON, drops the stale blob with it).
|
||||
CREATE TABLE IF NOT EXISTS tagging_candidates (
|
||||
group_key TEXT PRIMARY KEY,
|
||||
candidates TEXT NOT NULL,
|
||||
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(group_key) REFERENCES tagging_items(group_key) ON DELETE CASCADE
|
||||
);
|
||||
@@ -85,6 +85,10 @@ type Library struct {
|
||||
AutotagWarningAcked int64
|
||||
}
|
||||
|
||||
type LyricsIndex struct {
|
||||
Lyrics string
|
||||
}
|
||||
|
||||
type PlayHistory struct {
|
||||
ID int64
|
||||
AudioFileID int64
|
||||
@@ -100,12 +104,13 @@ type PlayerState struct {
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID int64
|
||||
Name string
|
||||
IsSmart int64
|
||||
SmartRules sql.NullString
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID int64
|
||||
Name string
|
||||
IsSmart int64
|
||||
SmartRules sql.NullString
|
||||
SmartSnapshotAt sql.NullTime
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistTrack struct {
|
||||
@@ -184,6 +189,12 @@ type SearchIndex struct {
|
||||
Album string
|
||||
}
|
||||
|
||||
type TaggingCandidate struct {
|
||||
GroupKey string
|
||||
Candidates string
|
||||
ComputedAt time.Time
|
||||
}
|
||||
|
||||
type TaggingItem struct {
|
||||
GroupKey string
|
||||
LibraryID int64
|
||||
|
||||
@@ -82,7 +82,7 @@ func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64,
|
||||
|
||||
const createPlaylist = `-- name: CreatePlaylist :one
|
||||
INSERT INTO playlists (name) VALUES (?)
|
||||
RETURNING id, name, is_smart, smart_rules, created_at, updated_at
|
||||
RETURNING id, name, is_smart, smart_rules, smart_snapshot_at, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, error) {
|
||||
@@ -93,6 +93,7 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er
|
||||
&i.Name,
|
||||
&i.IsSmart,
|
||||
&i.SmartRules,
|
||||
&i.SmartSnapshotAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -205,7 +206,7 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl
|
||||
}
|
||||
|
||||
const getAllPlaylists = `-- name: GetAllPlaylists :many
|
||||
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists ORDER BY updated_at DESC
|
||||
SELECT id, name, is_smart, smart_rules, smart_snapshot_at, created_at, updated_at FROM playlists ORDER BY updated_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
|
||||
@@ -222,6 +223,7 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
|
||||
&i.Name,
|
||||
&i.IsSmart,
|
||||
&i.SmartRules,
|
||||
&i.SmartSnapshotAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -251,7 +253,7 @@ func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID i
|
||||
}
|
||||
|
||||
const getPlaylist = `-- name: GetPlaylist :one
|
||||
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
|
||||
SELECT id, name, is_smart, smart_rules, smart_snapshot_at, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
|
||||
@@ -262,6 +264,7 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
|
||||
&i.Name,
|
||||
&i.IsSmart,
|
||||
&i.SmartRules,
|
||||
&i.SmartSnapshotAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -109,6 +109,17 @@ SELECT
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
@@ -131,6 +142,7 @@ type GetAlbumsByArtistRow struct {
|
||||
Year sql.NullInt64
|
||||
ReleaseYear int64
|
||||
ArtistName string
|
||||
ArtistMbid string
|
||||
CoverArtPath string
|
||||
}
|
||||
|
||||
@@ -149,6 +161,7 @@ func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetA
|
||||
&i.Year,
|
||||
&i.ReleaseYear,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
&i.CoverArtPath,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -171,6 +184,17 @@ SELECT
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
@@ -205,6 +229,7 @@ type GetAlbumsByArtistByLibraryRow struct {
|
||||
Year sql.NullInt64
|
||||
ReleaseYear int64
|
||||
ArtistName string
|
||||
ArtistMbid string
|
||||
CoverArtPath string
|
||||
}
|
||||
|
||||
@@ -223,6 +248,7 @@ func (q *Queries) GetAlbumsByArtistByLibrary(ctx context.Context, arg GetAlbumsB
|
||||
&i.Year,
|
||||
&i.ReleaseYear,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
&i.CoverArtPath,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -250,6 +276,17 @@ SELECT
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
rg.mbid,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
@@ -271,6 +308,7 @@ type GetAllAlbumsWithDetailsRow struct {
|
||||
ReleaseYear int64
|
||||
Mbid sql.NullString
|
||||
ArtistName string
|
||||
ArtistMbid string
|
||||
CoverArtPath string
|
||||
}
|
||||
|
||||
@@ -290,6 +328,7 @@ func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWi
|
||||
&i.ReleaseYear,
|
||||
&i.Mbid,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
&i.CoverArtPath,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -317,6 +356,17 @@ SELECT
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
rg.mbid,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
@@ -345,6 +395,7 @@ type GetAllAlbumsWithDetailsByLibraryRow struct {
|
||||
ReleaseYear int64
|
||||
Mbid sql.NullString
|
||||
ArtistName string
|
||||
ArtistMbid string
|
||||
CoverArtPath string
|
||||
}
|
||||
|
||||
@@ -364,6 +415,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
|
||||
&i.ReleaseYear,
|
||||
&i.Mbid,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
&i.CoverArtPath,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: tagging_candidates.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const deleteTaggingCandidates = `-- name: DeleteTaggingCandidates :exec
|
||||
DELETE FROM tagging_candidates
|
||||
WHERE group_key = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTaggingCandidates(ctx context.Context, groupKey string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteTaggingCandidates, groupKey)
|
||||
return err
|
||||
}
|
||||
|
||||
const getTaggingCandidates = `-- name: GetTaggingCandidates :one
|
||||
SELECT candidates FROM tagging_candidates
|
||||
WHERE group_key = ?
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTaggingCandidates(ctx context.Context, groupKey string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getTaggingCandidates, groupKey)
|
||||
var candidates string
|
||||
err := row.Scan(&candidates)
|
||||
return candidates, err
|
||||
}
|
||||
|
||||
const upsertTaggingCandidates = `-- name: UpsertTaggingCandidates :exec
|
||||
INSERT INTO tagging_candidates (group_key, candidates, computed_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(group_key) DO UPDATE SET
|
||||
candidates = excluded.candidates,
|
||||
computed_at = excluded.computed_at
|
||||
`
|
||||
|
||||
type UpsertTaggingCandidatesParams struct {
|
||||
GroupKey string
|
||||
Candidates string
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertTaggingCandidates(ctx context.Context, arg UpsertTaggingCandidatesParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertTaggingCandidates, arg.GroupKey, arg.Candidates)
|
||||
return err
|
||||
}
|
||||
@@ -127,7 +127,7 @@ SELECT
|
||||
ti.library_id,
|
||||
COALESCE(lb.name, '') AS library_name,
|
||||
COALESCE(lb.path, '') AS library_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key AND af.group_key != '' LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
ti.track_count,
|
||||
ti.album_name,
|
||||
ti.album_artist,
|
||||
@@ -543,7 +543,7 @@ SELECT
|
||||
ti.library_id,
|
||||
COALESCE(lb.name, '') AS library_name,
|
||||
COALESCE(lb.path, '') AS library_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key AND af.group_key != '' LIMIT 1), '') AS TEXT) AS sample_file_path,
|
||||
ti.track_count,
|
||||
ti.album_name,
|
||||
ti.album_artist,
|
||||
|
||||
@@ -283,6 +283,56 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPendingFolders_SampleFilePathUsesIndex guards the folder-list
|
||||
// query's per-row sample_file_path subquery against regressing to a
|
||||
// full table scan of audio_files. The `AND af.group_key != ”` guard
|
||||
// is load-bearing: without it SQLite can't prove the partial index
|
||||
// idx_audio_files_group_key (WHERE group_key != ”) applies, and the
|
||||
// subquery degrades to O(folders * audio_files) — the difference
|
||||
// between the review list loading instantly and taking a minute.
|
||||
func TestListPendingFolders_SampleFilePathUsesIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
rows, err := db.QueryContext(`
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT
|
||||
ti.group_key,
|
||||
CAST(COALESCE((SELECT af.file_path FROM audio_files af
|
||||
WHERE af.group_key = ti.group_key AND af.group_key != '' LIMIT 1), '') AS TEXT)
|
||||
FROM tagging_items ti
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("explain: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var plan strings.Builder
|
||||
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
|
||||
var detail string
|
||||
|
||||
if scanErr := rows.Scan(&id, &parent, ¬used, &detail); scanErr != nil {
|
||||
t.Fatalf("scan: %v", scanErr)
|
||||
}
|
||||
|
||||
plan.WriteString(detail)
|
||||
plan.WriteString("\n")
|
||||
}
|
||||
|
||||
if !strings.Contains(plan.String(), "idx_audio_files_group_key") {
|
||||
t.Errorf(
|
||||
"sample_file_path subquery no longer uses idx_audio_files_group_key "+
|
||||
"(would full-scan audio_files per folder):\n%s",
|
||||
plan.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -75,10 +75,13 @@ func NewTestDB(t *testing.T) *DB {
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return &DB{
|
||||
db: db,
|
||||
Ctx: ctx,
|
||||
Queries: queries,
|
||||
logger: slog.Default(),
|
||||
db: db,
|
||||
Ctx: ctx,
|
||||
// The in-memory test DB shares one connection, so reads and
|
||||
// writes use the same handle; ReadQueries aliases Queries.
|
||||
Queries: queries,
|
||||
ReadQueries: queries,
|
||||
logger: slog.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,4 +90,22 @@ const (
|
||||
// Explore / search index events.
|
||||
const (
|
||||
IndexStatusChanged = "IndexStatusChanged"
|
||||
|
||||
// ArtistDiscographyReady fires (payload: artist MBID string) after a
|
||||
// lazy background discography fetch persists into the index, so the
|
||||
// artist detail page can re-fetch its top tracks / top releases
|
||||
// without the initial request having blocked on a live fetch.
|
||||
ArtistDiscographyReady = "ArtistDiscographyReady"
|
||||
|
||||
// ArtistSimilarReady fires (payload: artist MBID string) after a lazy
|
||||
// background similar-artists fetch persists into similar_artist_map,
|
||||
// so the artist detail page can re-fetch that section without the
|
||||
// initial request having blocked on a live LB labs call.
|
||||
ArtistSimilarReady = "ArtistSimilarReady"
|
||||
|
||||
// AlbumReleasesReady fires (payload: release-group MBID string) after a
|
||||
// lazy background BrowseReleases fetch populates the response cache, so
|
||||
// the album detail page can re-fetch its versions / tracklist without
|
||||
// the initial request having blocked on a live MusicBrainz browse.
|
||||
AlbumReleasesReady = "AlbumReleasesReady"
|
||||
)
|
||||
|
||||
@@ -95,22 +95,52 @@ func (c *AutotagClient) LookupReleaseGroup(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LookupArtist returns the artist's sort name when available,
|
||||
// falling back to the display name. Used by the resolver when
|
||||
// constructing Lucene-style fallback queries.
|
||||
func (c *AutotagClient) LookupArtist(
|
||||
ctx context.Context, mbid string,
|
||||
) (string, error) {
|
||||
a, err := c.inner.LookupArtist(ctx, mbid)
|
||||
// SearchRecordings delegates to the wrapped client and projects hits
|
||||
// into autotag's minimal recording shape. Length is millisecond-
|
||||
// aligned to match local audio_files.
|
||||
func (c *AutotagClient) SearchRecordings(
|
||||
ctx context.Context, query string, limit int,
|
||||
) ([]autotag.MBRecordingHit, int, error) {
|
||||
recs, total, err := c.inner.SearchRecordings(ctx, query, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if a.SortName != "" {
|
||||
return a.SortName, nil
|
||||
out := make([]autotag.MBRecordingHit, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, autotag.MBRecordingHit{
|
||||
MBID: rec.MBID,
|
||||
Title: rec.Title,
|
||||
ArtistCredit: rec.ArtistCredit,
|
||||
LengthMillis: int64(rec.Length),
|
||||
})
|
||||
}
|
||||
|
||||
return a.Name, nil
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// LookupRecordingReleases returns the releases a recording appears on,
|
||||
// as slim references the resolver ranks to pick a representative
|
||||
// release.
|
||||
func (c *AutotagClient) LookupRecordingReleases(
|
||||
ctx context.Context, recordingMBID string,
|
||||
) ([]autotag.MBReleaseRef, error) {
|
||||
refs, err := c.inner.LookupRecordingReleases(ctx, recordingMBID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]autotag.MBReleaseRef, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
out = append(out, autotag.MBReleaseRef{
|
||||
MBID: r.MBID,
|
||||
Title: r.Title,
|
||||
Status: r.Status,
|
||||
Date: r.Date,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func exploreToAutotagRelease(rel MBRelease) autotag.MBRelease {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build unix
|
||||
|
||||
package explore
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// diskFreeBytes returns the free bytes available to the current user
|
||||
// on the filesystem containing path. ok is false when unknown.
|
||||
func diskFreeBytes(path string) (free uint64, ok bool) {
|
||||
var st unix.Statfs_t
|
||||
|
||||
if err := unix.Statfs(path, &st); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Bavail/Bsize integer types vary by platform.
|
||||
//nolint:unconvert,gosec
|
||||
return st.Bavail * uint64(st.Bsize), true
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package explore
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// diskFreeBytes returns the free bytes available to the current user
|
||||
// on the volume containing path. ok is false when unknown.
|
||||
func diskFreeBytes(path string) (free uint64, ok bool) {
|
||||
p, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var freeToCaller, total, totalFree uint64
|
||||
|
||||
if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &total, &totalFree); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return freeToCaller, true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/parquet-go/parquet-go"
|
||||
)
|
||||
|
||||
// Stage 1 of the dump import: stream the ListenBrainz spark listens
|
||||
// dump (a plain tar of ~128MB parquet files) and aggregate listen
|
||||
// counts per recording, release, and artist MBID. Nothing is written
|
||||
// to disk except counts.bin — each parquet member is buffered in RAM,
|
||||
// parsed, and discarded. The aggregate map lives in RAM (~40M entities
|
||||
// ≈ 2GB) and is flushed atomically with the stream byte offset so an
|
||||
// interrupted import resumes without re-downloading processed data.
|
||||
|
||||
const (
|
||||
// countKindRecording etc. tag entries in the counts map/file.
|
||||
countKindRecording = byte(1)
|
||||
countKindRelease = byte(2)
|
||||
countKindArtist = byte(3)
|
||||
|
||||
// countsFlushEveryMembers controls checkpoint frequency. Each
|
||||
// flush rewrites counts.bin (~1GB by the end), so this trades
|
||||
// checkpoint I/O against re-download on crash (~150 members ≈
|
||||
// 19GB of stream progress).
|
||||
countsFlushEveryMembers = 150
|
||||
|
||||
// countsProgressEveryMembers controls progress log frequency.
|
||||
countsProgressEveryMembers = 50
|
||||
|
||||
// parquetParseWorkers is the number of concurrent parquet
|
||||
// decoders. Bounded to limit RAM: each worker holds one
|
||||
// ~128MB member buffer.
|
||||
parquetParseWorkers = 3
|
||||
|
||||
// maxParquetMemberSize guards against unexpected dump format
|
||||
// changes blowing out RAM.
|
||||
maxParquetMemberSize = 1 << 30
|
||||
|
||||
// countsFileMagic identifies + versions the counts file format.
|
||||
countsFileMagic = "YJCNTS01"
|
||||
)
|
||||
|
||||
// ErrDumpFormat is returned when dump contents don't match the
|
||||
// expected format.
|
||||
var ErrDumpFormat = errors.New("unexpected dump format")
|
||||
|
||||
// mbidKey is a parsed UUID plus an entity-kind tag, used as the counts
|
||||
// map key. 17 bytes instead of a 36-byte string keeps the ~40M-entry
|
||||
// map around 2GB.
|
||||
type mbidKey [17]byte
|
||||
|
||||
func makeMBIDKey(kind byte, mbid string) (mbidKey, bool) {
|
||||
var k mbidKey
|
||||
|
||||
k[0] = kind
|
||||
|
||||
if !parseUUID(mbid, k[1:]) {
|
||||
return k, false
|
||||
}
|
||||
|
||||
return k, true
|
||||
}
|
||||
|
||||
// parseUUID parses a canonical 36-char UUID string into 16 bytes.
|
||||
// Returns false for anything malformed.
|
||||
func parseUUID(s string, out []byte) bool {
|
||||
if len(s) != 36 || s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
j := 0
|
||||
|
||||
for i := 0; i < 36; i++ {
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
continue
|
||||
}
|
||||
|
||||
hi := hexNibble(s[i])
|
||||
i++
|
||||
|
||||
lo := hexNibble(s[i])
|
||||
if hi == 0xFF || lo == 0xFF {
|
||||
return false
|
||||
}
|
||||
|
||||
out[j] = hi<<4 | lo
|
||||
j++
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func hexNibble(c byte) byte {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0'
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10
|
||||
default:
|
||||
return 0xFF
|
||||
}
|
||||
}
|
||||
|
||||
func formatUUID(b []byte) string {
|
||||
const hexdigits = "0123456789abcdef"
|
||||
|
||||
out := make([]byte, 36)
|
||||
j := 0
|
||||
|
||||
for i := range 16 {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
out[j] = '-'
|
||||
j++
|
||||
}
|
||||
|
||||
out[j] = hexdigits[b[i]>>4]
|
||||
out[j+1] = hexdigits[b[i]&0x0F]
|
||||
j += 2
|
||||
}
|
||||
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// countsState is the checkpointed stage-1 state: the counts map plus
|
||||
// the stream position it corresponds to.
|
||||
type countsState struct {
|
||||
// SparkURL pins the dump being processed so a resume never mixes
|
||||
// two different dumps.
|
||||
SparkURL string `json:"sparkUrl"`
|
||||
|
||||
// Offset is the byte offset of the next unprocessed tar member
|
||||
// header (exact — includes the padding of the previous member).
|
||||
Offset int64 `json:"offset"`
|
||||
|
||||
// MemberIdx is the index of the next unprocessed parquet member
|
||||
// (logging only; Offset is authoritative for resume).
|
||||
MemberIdx int `json:"memberIdx"`
|
||||
|
||||
// Done marks stage 1 complete.
|
||||
Done bool `json:"done"`
|
||||
|
||||
counts map[mbidKey]uint32
|
||||
}
|
||||
|
||||
// sparkListenRow is the projection of the spark listens parquet schema
|
||||
// that the aggregator reads. All other columns are skipped.
|
||||
type sparkListenRow struct {
|
||||
RecordingMBID string `parquet:"recording_mbid,optional"`
|
||||
ReleaseMBID string `parquet:"release_mbid,optional"`
|
||||
ArtistMBIDs []string `parquet:"artist_credit_mbids,optional,list"`
|
||||
}
|
||||
|
||||
type countParseJob struct {
|
||||
idx int
|
||||
endOffset int64 // exact offset of the next member header
|
||||
buf []byte
|
||||
}
|
||||
|
||||
type countParseResult struct {
|
||||
idx int
|
||||
endOffset int64
|
||||
deltas map[mbidKey]uint32
|
||||
err error
|
||||
}
|
||||
|
||||
// aggregateListenCounts runs stage 1 to completion (or ctx cancel),
|
||||
// checkpointing to the staging counts file as it goes.
|
||||
func (imp *dumpImporter) aggregateListenCounts(ctx context.Context, st *countsState) error {
|
||||
if st.counts == nil {
|
||||
st.counts = make(map[mbidKey]uint32, 1<<20)
|
||||
}
|
||||
|
||||
stream := newResumableReader(ctx, imp.httpClient, st.SparkURL, st.Offset)
|
||||
|
||||
defer func() { _ = stream.Close() }()
|
||||
|
||||
buffered := bufio.NewReaderSize(stream, 1<<20)
|
||||
tr := tar.NewReader(buffered)
|
||||
|
||||
// consumedOffset is the absolute stream position of everything the
|
||||
// tar reader has consumed: bytes delivered by HTTP minus bytes
|
||||
// still sitting in the bufio buffer.
|
||||
consumedOffset := func() int64 {
|
||||
return stream.Offset - int64(buffered.Buffered())
|
||||
}
|
||||
|
||||
jobs := make(chan countParseJob)
|
||||
results := make(chan countParseResult, parquetParseWorkers)
|
||||
applierDone := make(chan struct{})
|
||||
bufPool := sync.Pool{New: func() any { return []byte(nil) }}
|
||||
|
||||
var workerWG sync.WaitGroup
|
||||
|
||||
for range parquetParseWorkers {
|
||||
workerWG.Add(1)
|
||||
|
||||
go func() {
|
||||
defer workerWG.Done()
|
||||
|
||||
for job := range jobs {
|
||||
deltas, err := parseListenParquet(job.buf)
|
||||
// Buffer reuse across members is intentional.
|
||||
bufPool.Put(job.buf[:0]) //nolint:staticcheck
|
||||
|
||||
results <- countParseResult{
|
||||
idx: job.idx,
|
||||
endOffset: job.endOffset,
|
||||
deltas: deltas,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// The applier merges results into st in member order, so every
|
||||
// checkpoint is a contiguous prefix of the stream. It owns
|
||||
// st.counts, st.Offset, and st.MemberIdx until applierDone closes;
|
||||
// on error it keeps draining results so nothing deadlocks.
|
||||
var applyErr error
|
||||
|
||||
go func() {
|
||||
defer close(applierDone)
|
||||
|
||||
pending := make(map[int]countParseResult)
|
||||
next := st.MemberIdx
|
||||
lastFlushed := st.MemberIdx
|
||||
|
||||
for res := range results {
|
||||
if applyErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pending[res.idx] = res
|
||||
|
||||
for {
|
||||
r, ok := pending[next]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
delete(pending, next)
|
||||
|
||||
if r.err != nil {
|
||||
applyErr = r.err
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
for k, v := range r.deltas {
|
||||
st.counts[k] += v
|
||||
}
|
||||
|
||||
next++
|
||||
st.MemberIdx = next
|
||||
st.Offset = r.endOffset
|
||||
|
||||
if next-lastFlushed >= countsFlushEveryMembers {
|
||||
if err := imp.writeCountsFile(st); err != nil {
|
||||
applyErr = err
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
lastFlushed = next
|
||||
|
||||
imp.logCountsProgress(next, r.endOffset, stream.Size, len(st.counts))
|
||||
|
||||
if err := imp.checkDiskHeadroom(); err != nil {
|
||||
applyErr = err
|
||||
|
||||
break
|
||||
}
|
||||
} else if next%countsProgressEveryMembers == 0 {
|
||||
imp.logCountsProgress(next, r.endOffset, stream.Size, len(st.counts))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
memberIdx := st.MemberIdx
|
||||
readErr := error(nil)
|
||||
|
||||
readLoop:
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
readErr = err
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
readErr = fmt.Errorf("listens tar: %w", err)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if hdr.Typeflag != tar.TypeReg || !strings.HasSuffix(hdr.Name, ".parquet") {
|
||||
continue
|
||||
}
|
||||
|
||||
if hdr.Size > maxParquetMemberSize {
|
||||
readErr = fmt.Errorf("%w: parquet member %s is %d bytes", ErrDumpFormat, hdr.Name, hdr.Size)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
buf, _ := bufPool.Get().([]byte)
|
||||
if cap(buf) < int(hdr.Size) {
|
||||
buf = make([]byte, hdr.Size)
|
||||
}
|
||||
|
||||
buf = buf[:hdr.Size]
|
||||
|
||||
if _, err := io.ReadFull(tr, buf); err != nil {
|
||||
readErr = fmt.Errorf("listens tar member read: %w", err)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// Exact next-header offset: position after the entry data plus
|
||||
// the entry's block padding. Correct even when the next member
|
||||
// uses PAX extension headers (those start at its header offset).
|
||||
endOffset := consumedOffset() + tarPadding(hdr.Size)
|
||||
|
||||
select {
|
||||
case jobs <- countParseJob{idx: memberIdx, endOffset: endOffset, buf: buf}:
|
||||
case <-ctx.Done():
|
||||
readErr = ctx.Err()
|
||||
|
||||
break readLoop
|
||||
}
|
||||
|
||||
memberIdx++
|
||||
}
|
||||
|
||||
close(jobs)
|
||||
workerWG.Wait()
|
||||
close(results)
|
||||
<-applierDone
|
||||
|
||||
if readErr == nil {
|
||||
readErr = applyErr
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
// Best-effort checkpoint of applied progress before bailing,
|
||||
// so even a cancelled run resumes where it left off.
|
||||
_ = imp.writeCountsFile(st)
|
||||
|
||||
return readErr
|
||||
}
|
||||
|
||||
st.Done = true
|
||||
|
||||
if err := imp.writeCountsFile(st); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: listen counts complete",
|
||||
"members", st.MemberIdx,
|
||||
"entities", len(st.counts),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tarPadding returns the number of zero bytes following a tar entry of
|
||||
// the given size (entries are padded to 512-byte blocks).
|
||||
func tarPadding(size int64) int64 {
|
||||
const block = 512
|
||||
|
||||
return (block - size%block) % block
|
||||
}
|
||||
|
||||
// parseListenParquet decodes one parquet member and returns the
|
||||
// per-entity listen-count deltas.
|
||||
func parseListenParquet(buf []byte) (map[mbidKey]uint32, error) {
|
||||
reader := parquet.NewGenericReader[sparkListenRow](bytes.NewReader(buf))
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
deltas := make(map[mbidKey]uint32, 1<<18)
|
||||
rows := make([]sparkListenRow, 4096)
|
||||
|
||||
for {
|
||||
n, err := reader.Read(rows)
|
||||
|
||||
for _, row := range rows[:n] {
|
||||
key, ok := makeMBIDKey(countKindRecording, row.RecordingMBID)
|
||||
if !ok {
|
||||
// Unmapped listen — no usable recording MBID.
|
||||
continue
|
||||
}
|
||||
|
||||
deltas[key]++
|
||||
|
||||
if relKey, relOK := makeMBIDKey(countKindRelease, row.ReleaseMBID); relOK {
|
||||
deltas[relKey]++
|
||||
}
|
||||
|
||||
for _, artist := range row.ArtistMBIDs {
|
||||
if artKey, artOK := makeMBIDKey(countKindArtist, artist); artOK {
|
||||
deltas[artKey]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parquet read: %w", err)
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return deltas, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// counts.bin persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// writeCountsFile atomically persists the counts map + stream position
|
||||
// (write to temp file, fsync, rename).
|
||||
func (imp *dumpImporter) writeCountsFile(st *countsState) error {
|
||||
tmp := imp.countsPath() + ".tmp"
|
||||
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("counts file create: %w", err)
|
||||
}
|
||||
|
||||
w := bufio.NewWriterSize(f, 1<<20)
|
||||
|
||||
meta, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return fmt.Errorf("counts meta marshal: %w", err)
|
||||
}
|
||||
|
||||
_, _ = w.WriteString(countsFileMagic)
|
||||
|
||||
var lenBuf [4]byte
|
||||
|
||||
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(meta)))
|
||||
_, _ = w.Write(lenBuf[:])
|
||||
_, _ = w.Write(meta)
|
||||
|
||||
var rec [21]byte
|
||||
|
||||
for k, v := range st.counts {
|
||||
copy(rec[:17], k[:])
|
||||
binary.LittleEndian.PutUint32(rec[17:], v)
|
||||
|
||||
if _, err := w.Write(rec[:]); err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return fmt.Errorf("counts file write: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.Flush(); err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return fmt.Errorf("counts file flush: %w", err)
|
||||
}
|
||||
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return fmt.Errorf("counts file sync: %w", err)
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("counts file close: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, imp.countsPath()); err != nil {
|
||||
return fmt.Errorf("counts file rename: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readCountsFile loads a previously checkpointed counts file. Returns
|
||||
// (nil, nil) when no checkpoint exists.
|
||||
func (imp *dumpImporter) readCountsFile() (*countsState, error) {
|
||||
f, err := os.Open(imp.countsPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil //nolint:nilnil // no checkpoint is a valid, non-error state
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("counts file open: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
r := bufio.NewReaderSize(f, 1<<20)
|
||||
|
||||
magic := make([]byte, len(countsFileMagic))
|
||||
if _, err := io.ReadFull(r, magic); err != nil || string(magic) != countsFileMagic {
|
||||
return nil, fmt.Errorf("%w: bad counts file header", ErrDumpFormat)
|
||||
}
|
||||
|
||||
var lenBuf [4]byte
|
||||
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, fmt.Errorf("counts meta length: %w", err)
|
||||
}
|
||||
|
||||
meta := make([]byte, binary.LittleEndian.Uint32(lenBuf[:]))
|
||||
if _, err := io.ReadFull(r, meta); err != nil {
|
||||
return nil, fmt.Errorf("counts meta read: %w", err)
|
||||
}
|
||||
|
||||
st := &countsState{}
|
||||
if err := json.Unmarshal(meta, st); err != nil {
|
||||
return nil, fmt.Errorf("counts meta unmarshal: %w", err)
|
||||
}
|
||||
|
||||
st.counts = make(map[mbidKey]uint32, 1<<20)
|
||||
|
||||
var rec [21]byte
|
||||
|
||||
for {
|
||||
if _, err := io.ReadFull(r, rec[:]); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("counts record read: %w", err)
|
||||
}
|
||||
|
||||
var k mbidKey
|
||||
|
||||
copy(k[:], rec[:17])
|
||||
st.counts[k] = binary.LittleEndian.Uint32(rec[17:])
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (imp *dumpImporter) logCountsProgress(members int, offset, size int64, entities int) {
|
||||
pct := float64(0)
|
||||
if size > 0 {
|
||||
pct = float64(offset) / float64(size) * 100
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: listen counts progress",
|
||||
"members", members,
|
||||
"gb", fmt.Sprintf("%.1f", float64(offset)/(1<<30)),
|
||||
"pct", fmt.Sprintf("%.1f", pct),
|
||||
"entities", entities,
|
||||
)
|
||||
|
||||
imp.setStageProgress(dumpStageCounts, int(pct), 100)
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/system"
|
||||
)
|
||||
|
||||
// Dump-based index population. Instead of crawling the ListenBrainz
|
||||
// API artist-by-artist, the index is built from two MetaBrainz dumps:
|
||||
//
|
||||
// 1. The spark listens dump (~170GB, streamed, never stored) yields
|
||||
// listen counts for every recording/release/artist MBID.
|
||||
// 2. The MusicBrainz canonical dump (~2GB, streamed) yields names and
|
||||
// MBIDs, filtered to entities above a popularity floor.
|
||||
//
|
||||
// A short API patch pass then fills listener counts (top rows only),
|
||||
// artist metadata, and the similar-artist map. Total temporary disk
|
||||
// is one ~1GB counts file, deleted on completion. The pipeline
|
||||
// resumes from checkpoints after interruption.
|
||||
|
||||
const (
|
||||
// dumpImportDoneKey marks a completed import in explore_index_meta.
|
||||
dumpImportDoneKey = "dump_import_done"
|
||||
|
||||
// listensAppliedSeriesKey stores the dump series number whose listen
|
||||
// counts are folded into popularity (the high-water-mark for the
|
||||
// incremental refresh). Set to the full dump's series at import, then
|
||||
// advanced by each applied incremental.
|
||||
listensAppliedSeriesKey = "listens_applied_series"
|
||||
|
||||
// releaseToRGInsertBatch bounds how many rows are written per
|
||||
// transaction when persisting the release→release-group map.
|
||||
releaseToRGInsertBatch = 10_000
|
||||
|
||||
// dumpMinStartFreeBytes is the free-disk requirement to begin an
|
||||
// import (counts file + index growth + headroom).
|
||||
dumpMinStartFreeBytes = 6 << 30
|
||||
|
||||
// dumpAbortFreeBytes aborts a running import when free disk
|
||||
// drops below it.
|
||||
dumpAbortFreeBytes = 2 << 30
|
||||
|
||||
// dumpStageAssembled in state.json means the index rows are
|
||||
// written and only patch passes remain.
|
||||
dumpStageAssembled = "assembled"
|
||||
)
|
||||
|
||||
// ErrDiskSpace is returned when free disk falls below the safety floor.
|
||||
var ErrDiskSpace = errors.New("insufficient free disk space")
|
||||
|
||||
// Dump import stages, mapped to status names shown in the UI.
|
||||
const (
|
||||
dumpStageCounts = iota
|
||||
dumpStageCatalog
|
||||
dumpStagePatch
|
||||
dumpStageListeners
|
||||
)
|
||||
|
||||
var dumpStageNames = [...]string{
|
||||
"Listen Counts",
|
||||
"Catalog Import",
|
||||
"Metadata Patch",
|
||||
"Listener Counts",
|
||||
}
|
||||
|
||||
// Dump discovery patterns.
|
||||
var (
|
||||
canonicalDirRe = regexp.MustCompile(`^musicbrainz-canonical-dump-\d{8}-\d+$`)
|
||||
canonicalFileRe = regexp.MustCompile(`^musicbrainz-canonical-dump-.*\.tar\.zst$`)
|
||||
listensDirRe = regexp.MustCompile(`^listenbrainz-dump-\d+-\d{8}-\d+-full$`)
|
||||
sparkFileRe = regexp.MustCompile(`^listenbrainz-spark-dump-.*-full\.tar$`)
|
||||
)
|
||||
|
||||
// Production dump locations (overridable for tests).
|
||||
const (
|
||||
defaultCanonicalBaseURL = "https://data.metabrainz.org/pub/musicbrainz/canonical_data/"
|
||||
defaultListensBaseURL = "https://data.metabrainz.org/pub/musicbrainz/listenbrainz/fullexport/"
|
||||
)
|
||||
|
||||
// dumpImportState is the small persistent state file (staging dir).
|
||||
// The heavyweight stage-1 checkpoint lives in counts.bin.
|
||||
type dumpImportState struct {
|
||||
SparkURL string `json:"sparkUrl"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
Stage string `json:"stage"`
|
||||
}
|
||||
|
||||
// dumpImporter runs the dump import pipeline.
|
||||
type dumpImporter struct {
|
||||
si *SearchIndex
|
||||
lb *ListenBrainzClient
|
||||
logger *slog.Logger
|
||||
|
||||
httpClient *http.Client
|
||||
stagingDir string
|
||||
|
||||
canonicalBaseURL string
|
||||
listensBaseURL string
|
||||
|
||||
// Disk safety floors (fields so tests can relax them).
|
||||
minStartFreeBytes uint64
|
||||
abortFreeBytes uint64
|
||||
|
||||
// pendingArtists are kept artists whose names weren't derivable
|
||||
// from the canonical dump; the metadata patch pass resolves them.
|
||||
pendingArtists []string
|
||||
}
|
||||
|
||||
func newDumpImporter(si *SearchIndex, lb *ListenBrainzClient) (*dumpImporter, error) {
|
||||
dataDir, err := system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dump import: data dir: %w", err)
|
||||
}
|
||||
|
||||
stagingDir := filepath.Join(dataDir, "explore-staging")
|
||||
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("dump import: staging dir: %w", err)
|
||||
}
|
||||
|
||||
return &dumpImporter{
|
||||
si: si,
|
||||
lb: lb,
|
||||
logger: si.logger,
|
||||
// No client-level timeout: the listens stream runs for hours.
|
||||
// Discovery requests use per-request context timeouts, and
|
||||
// resumableReader recovers from stalled connections.
|
||||
httpClient: &http.Client{},
|
||||
stagingDir: stagingDir,
|
||||
canonicalBaseURL: defaultCanonicalBaseURL,
|
||||
listensBaseURL: defaultListensBaseURL,
|
||||
minStartFreeBytes: dumpMinStartFreeBytes,
|
||||
abortFreeBytes: dumpAbortFreeBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (imp *dumpImporter) countsPath() string {
|
||||
return filepath.Join(imp.stagingDir, "counts.bin")
|
||||
}
|
||||
|
||||
func (imp *dumpImporter) statePath() string {
|
||||
return filepath.Join(imp.stagingDir, "state.json")
|
||||
}
|
||||
|
||||
// run executes the pipeline, resuming from any prior checkpoint.
|
||||
func (imp *dumpImporter) run(ctx context.Context) error {
|
||||
if err := checkFreeDisk(imp.stagingDir, imp.minStartFreeBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state, err := imp.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fast path: rows already assembled, only patch passes remain.
|
||||
if state.Stage == dumpStageAssembled {
|
||||
imp.si.MarkReadyIfPopulated()
|
||||
imp.runPatchPasses(ctx)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return imp.finalize()
|
||||
}
|
||||
|
||||
// Stage 1: listen counts from the spark listens dump.
|
||||
counts, err := imp.readCountsFile()
|
||||
if err != nil {
|
||||
imp.logger.Warn("dump import: discarding unreadable counts checkpoint", "error", err)
|
||||
|
||||
counts = nil
|
||||
}
|
||||
|
||||
if counts == nil {
|
||||
sparkURL, err := discoverDumpFile(
|
||||
ctx, imp.httpClient, imp.listensBaseURL, listensDirRe, sparkFileRe,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: starting", "listensDump", sparkURL)
|
||||
|
||||
counts = &countsState{SparkURL: sparkURL}
|
||||
} else if !counts.Done {
|
||||
imp.logger.Info("dump import: resuming listen counts",
|
||||
"offset", counts.Offset,
|
||||
"members", counts.MemberIdx,
|
||||
"entities", len(counts.counts),
|
||||
)
|
||||
}
|
||||
|
||||
// Record which dump series this import is baselined on, so the
|
||||
// incremental refresh knows where to resume applying daily deltas.
|
||||
// Written early (before assembly) so it survives a crash-and-resume;
|
||||
// incrementals only apply once dumpImportDoneKey confirms completion.
|
||||
imp.recordDumpSeries(counts.SparkURL)
|
||||
|
||||
imp.setStageProgress(dumpStageCounts, 0, 100)
|
||||
|
||||
if !counts.Done {
|
||||
if err := imp.aggregateListenCounts(ctx, counts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
imp.si.setTierStatus(dumpStageNames[dumpStageCounts], "complete", 100, 100)
|
||||
|
||||
// Stage 2: popularity thresholds (in RAM, deterministic).
|
||||
kept := imp.computeThreshold(counts.counts)
|
||||
|
||||
// Free the full counts map; only the kept sets are needed now.
|
||||
counts.counts = nil
|
||||
|
||||
// Stage 3: canonical dump scan + assembly. Restartable: the
|
||||
// scan is a cheap 2GB stream and assembly is an idempotent
|
||||
// upsert, so no intra-stage checkpoint is needed.
|
||||
if state.CanonicalURL == "" {
|
||||
state.CanonicalURL, err = discoverDumpFile(
|
||||
ctx, imp.httpClient, imp.canonicalBaseURL, canonicalDirRe, canonicalFileRe,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := imp.writeState(state); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
imp.setStageProgress(dumpStageCatalog, 0, 0)
|
||||
|
||||
scan, err := imp.scanCanonicalDump(ctx, state.CanonicalURL, kept)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := imp.checkDiskHeadroom(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One-time reset when migrating from the legacy API-crawled
|
||||
// index: its popularity values are on a different scale (the LB
|
||||
// API includes MLHD+ history) and would permanently outrank
|
||||
// dump-derived counts via the highest-wins upsert. Re-imports
|
||||
// (dump→dump) skip this — listen counts only grow.
|
||||
if !imp.si.hasMeta(dumpImportDoneKey) {
|
||||
if _, err := imp.si.db.ExecContext("DELETE FROM explore_index"); err == nil {
|
||||
imp.logger.Info("dump import: cleared legacy index for consistent popularity scale")
|
||||
}
|
||||
}
|
||||
|
||||
if err := imp.assembleIndex(ctx, kept, scan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Persist the release→release-group map (otherwise in-memory only)
|
||||
// so incremental dumps can roll per-release listen deltas up to their
|
||||
// album without an API call. Kept in lockstep with the index it was
|
||||
// just built from.
|
||||
imp.persistReleaseToRG(ctx, scan.releaseToRG)
|
||||
|
||||
imp.si.setTierStatus(dumpStageNames[dumpStageCatalog], "complete", 0, 0)
|
||||
|
||||
// Artists that need names from the metadata patch pass.
|
||||
for mbid := range kept.artists {
|
||||
if _, ok := scan.artistNames[mbid]; !ok {
|
||||
imp.pendingArtists = append(imp.pendingArtists, formatUUID(mbid[:]))
|
||||
}
|
||||
}
|
||||
|
||||
state.Stage = dumpStageAssembled
|
||||
if err := imp.writeState(state); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imp.si.MarkReadyIfPopulated()
|
||||
imp.si.refreshStatusCounts()
|
||||
|
||||
// Stage 4: API patch passes (idempotent).
|
||||
imp.runPatchPasses(ctx)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return imp.finalize()
|
||||
}
|
||||
|
||||
// finalize records completion and removes all staging data.
|
||||
func (imp *dumpImporter) finalize() error {
|
||||
imp.si.setMeta(dumpImportDoneKey, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// Retire the legacy tier-crawl freshness keys.
|
||||
_, _ = imp.si.db.ExecContext(
|
||||
`DELETE FROM explore_index_meta
|
||||
WHERE key IN ('tier1_built', 'tier2_built', 'tier3_built', 'tier4_built')`,
|
||||
)
|
||||
|
||||
if err := os.RemoveAll(imp.stagingDir); err != nil {
|
||||
imp.logger.Warn("dump import: staging cleanup failed", "error", err)
|
||||
}
|
||||
|
||||
imp.si.setTierStatus(dumpStageNames[dumpStagePatch], "complete", 0, 0)
|
||||
imp.si.setTierStatus(dumpStageNames[dumpStageListeners], "complete", 0, 0)
|
||||
imp.si.refreshStatusCounts()
|
||||
|
||||
// The imported catalog changed which rows are popular, so refresh the
|
||||
// champion tier used for generic short-prefix searches.
|
||||
imp.si.scheduleChampionRebuild()
|
||||
|
||||
imp.logger.Info("dump import: complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dumpSeriesRe extracts the monotonic series number NNNN from a dump
|
||||
// URL or directory name (e.g. "listenbrainz-spark-dump-2593-…").
|
||||
var dumpSeriesRe = regexp.MustCompile(`listenbrainz-(?:spark-)?dump-(\d+)-`)
|
||||
|
||||
// parseDumpSeries pulls the series number out of a dump URL/name.
|
||||
func parseDumpSeries(url string) (int, bool) {
|
||||
m := dumpSeriesRe.FindStringSubmatch(url)
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return n, true
|
||||
}
|
||||
|
||||
// recordDumpSeries stores the baseline series number for this import.
|
||||
func (imp *dumpImporter) recordDumpSeries(sparkURL string) {
|
||||
series, ok := parseDumpSeries(sparkURL)
|
||||
if !ok {
|
||||
imp.logger.Warn("dump import: could not parse dump series", "url", sparkURL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
imp.si.setMeta(listensAppliedSeriesKey, strconv.Itoa(series))
|
||||
}
|
||||
|
||||
// persistReleaseToRG replaces the release_to_rg table with the mapping
|
||||
// captured during this import, so it always reflects the just-built
|
||||
// index. Idempotent: a full rebuild clears and repopulates it.
|
||||
func (imp *dumpImporter) persistReleaseToRG(ctx context.Context, m map[uuid16]rgTarget) {
|
||||
if len(m) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := imp.si.db.ExecContext("DELETE FROM release_to_rg"); err != nil {
|
||||
imp.logger.Warn("dump import: clear release_to_rg failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
written := 0
|
||||
pending := 0
|
||||
|
||||
tx, err := imp.si.db.BeginTx()
|
||||
if err != nil {
|
||||
imp.logger.Warn("dump import: begin release_to_rg tx failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for rel, target := range m {
|
||||
if ctx.Err() != nil {
|
||||
_ = tx.Rollback()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"INSERT OR REPLACE INTO release_to_rg (release_mbid, rg_mbid) VALUES (?, ?)",
|
||||
formatUUID(rel[:]), formatUUID(target.rg[:]),
|
||||
); err != nil {
|
||||
imp.logger.Warn("dump import: insert release_to_rg failed", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
written++
|
||||
pending++
|
||||
|
||||
if pending >= releaseToRGInsertBatch {
|
||||
if err := tx.Commit(); err != nil {
|
||||
imp.logger.Warn("dump import: commit release_to_rg batch failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pending = 0
|
||||
|
||||
tx, err = imp.si.db.BeginTx()
|
||||
if err != nil {
|
||||
imp.logger.Warn("dump import: begin release_to_rg tx failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
imp.logger.Warn("dump import: commit release_to_rg failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: persisted release→release-group map", "rows", written)
|
||||
}
|
||||
|
||||
func (imp *dumpImporter) readState() (*dumpImportState, error) {
|
||||
state := &dumpImportState{}
|
||||
|
||||
data, err := os.ReadFile(imp.statePath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dump import state read: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, state); err != nil {
|
||||
// Corrupt state: start over rather than fail permanently.
|
||||
return &dumpImportState{}, nil
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (imp *dumpImporter) writeState(state *dumpImportState) error {
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dump import state marshal: %w", err)
|
||||
}
|
||||
|
||||
tmp := imp.statePath() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return fmt.Errorf("dump import state write: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, imp.statePath()); err != nil {
|
||||
return fmt.Errorf("dump import state rename: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setStageProgress reports stage progress to the UI status feed.
|
||||
func (imp *dumpImporter) setStageProgress(stage, completed, total int) {
|
||||
imp.si.setTierStatus(dumpStageNames[stage], "running", total, completed)
|
||||
}
|
||||
|
||||
// checkDiskHeadroom aborts the import when free disk is critically low.
|
||||
func (imp *dumpImporter) checkDiskHeadroom() error {
|
||||
return checkFreeDisk(imp.stagingDir, imp.abortFreeBytes)
|
||||
}
|
||||
|
||||
// checkFreeDisk returns ErrDiskSpace when the volume holding path has
|
||||
// less than minBytes free. Unknown free space (unsupported platform)
|
||||
// passes.
|
||||
func checkFreeDisk(path string, minBytes uint64) error {
|
||||
free, ok := diskFreeBytes(path)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if free < minBytes {
|
||||
return fmt.Errorf("%w: %d MB free, need %d MB",
|
||||
ErrDiskSpace, free>>20, minBytes>>20)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SearchIndex integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// runDumpBuild is the build entrypoint called from StartBuild's
|
||||
// goroutine. It replaces the legacy tier crawl.
|
||||
func (si *SearchIndex) runDumpBuild(ctx context.Context) {
|
||||
si.MarkReadyIfPopulated()
|
||||
|
||||
// The catalog dump is authoritative and only grows; it is imported
|
||||
// once and never re-crawled on a timer. Popularity freshness comes
|
||||
// from incremental dumps, and new releases from lazy per-artist
|
||||
// fetches — not from re-running this multi-GB import.
|
||||
if si.hasMeta(dumpImportDoneKey) {
|
||||
si.logger.Info("search index: dump import already complete, skipping")
|
||||
si.refreshStatusCounts()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
si.mu.Lock()
|
||||
si.buildStatus = IndexStatus{
|
||||
Building: true,
|
||||
Tiers: []TierStatus{
|
||||
{Name: dumpStageNames[dumpStageCounts], State: "pending"},
|
||||
{Name: dumpStageNames[dumpStageCatalog], State: "pending"},
|
||||
{Name: dumpStageNames[dumpStagePatch], State: "pending"},
|
||||
{Name: dumpStageNames[dumpStageListeners], State: "pending"},
|
||||
},
|
||||
}
|
||||
si.mu.Unlock()
|
||||
si.refreshStatusCounts()
|
||||
|
||||
// Patch passes use a dedicated rate limiter so background API
|
||||
// calls never compete with interactive search/browse requests.
|
||||
var indexLB *ListenBrainzClient
|
||||
|
||||
if si.lb != nil {
|
||||
indexLB = NewListenBrainzClient(
|
||||
NewRateLimiterN(indexerRate), si.lb.cache, si.logger.WithGroup("indexer"),
|
||||
)
|
||||
}
|
||||
|
||||
imp, err := newDumpImporter(si, indexLB)
|
||||
if err != nil {
|
||||
si.logger.Error("search index: dump import init failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
if err := imp.run(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
si.logger.Info("search index: dump import paused (will resume)",
|
||||
"elapsed", time.Since(start).Round(time.Second),
|
||||
)
|
||||
} else {
|
||||
si.logger.Error("search index: dump import failed", "error", err)
|
||||
si.setTierError(dumpStageNames[dumpStageCounts], err.Error())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
si.mu.Lock()
|
||||
si.buildStatus.Building = false
|
||||
si.mu.Unlock()
|
||||
|
||||
// Fold the local library into the freshly-imported catalog: owned
|
||||
// entities below the dump's popularity floor are inserted, and
|
||||
// dump-seeded rows that match the library are flagged in_library.
|
||||
// Deep discographies stay lazy (fetched when an artist page opens).
|
||||
si.PopulateLocalCrossReferences()
|
||||
|
||||
si.refreshStatusCounts()
|
||||
si.logger.Info("search index: dump import finished",
|
||||
"elapsed", time.Since(start).Round(time.Second),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// Fixed MBIDs for fixtures.
|
||||
const (
|
||||
recA = "11111111-1111-1111-1111-111111111111"
|
||||
recB = "22222222-2222-2222-2222-222222222222"
|
||||
recC = "33333333-3333-3333-3333-333333333333"
|
||||
relA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
relB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
rgA = "cccccccc-cccc-cccc-cccc-cccccccccccc"
|
||||
rgB = "dddddddd-dddd-dddd-dddd-dddddddddddd"
|
||||
artA = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"
|
||||
artB = "ffffffff-ffff-ffff-ffff-ffffffffffff"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.DiscardHandler)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseUUIDRoundTrip(t *testing.T) {
|
||||
var buf [16]byte
|
||||
|
||||
if !parseUUID(recA, buf[:]) {
|
||||
t.Fatalf("parseUUID rejected valid UUID %s", recA)
|
||||
}
|
||||
|
||||
if got := formatUUID(buf[:]); got != recA {
|
||||
t.Fatalf("round trip = %q, want %q", got, recA)
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"", "not-a-uuid",
|
||||
"11111111-1111-1111-1111-11111111111", // too short
|
||||
"11111111-1111-1111-1111-1111111111111", // too long
|
||||
"1111111101111-1111-1111-111111111111", // bad dash
|
||||
"gggggggg-1111-1111-1111-111111111111", // bad hex
|
||||
}
|
||||
|
||||
for _, s := range invalid {
|
||||
if parseUUID(s, buf[:]) {
|
||||
t.Errorf("parseUUID accepted invalid input %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePGStringArray(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"{" + artA + "}", []string{artA}},
|
||||
{"{" + artA + "," + artB + "}", []string{artA, artB}},
|
||||
{`{"` + artA + `","` + artB + `"}`, []string{artA, artB}},
|
||||
{"['" + artA + "', '" + artB + "']", []string{artA, artB}},
|
||||
{artA, []string{artA}},
|
||||
{"", nil},
|
||||
{"{}", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := parsePGStringArray(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("parsePGStringArray(%q) = %v, want %v", c.in, got, c.want)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("parsePGStringArray(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFloorForBudget(t *testing.T) {
|
||||
vals := []uint32{100, 90, 80, 70, 60, 50, 40, 30, 20, 5}
|
||||
|
||||
if got := floorForBudget(vals, 3, 10); got != 80 {
|
||||
t.Errorf("budget 3: floor = %d, want 80", got)
|
||||
}
|
||||
|
||||
// Budget larger than data → min floor.
|
||||
if got := floorForBudget(vals, 100, 10); got != 10 {
|
||||
t.Errorf("budget 100: floor = %d, want 10", got)
|
||||
}
|
||||
|
||||
// Floor clamped up to minFloor.
|
||||
if got := floorForBudget(vals, 10, 10); got != 10 {
|
||||
t.Errorf("clamp: floor = %d, want 10", got)
|
||||
}
|
||||
|
||||
if got := floorForBudget(nil, 5, 7); got != 7 {
|
||||
t.Errorf("empty: floor = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankFloor(t *testing.T) {
|
||||
desc := []uint32{100, 90, 80, 70, 60}
|
||||
|
||||
cases := []struct {
|
||||
rank int
|
||||
want uint32
|
||||
}{
|
||||
{1, 100},
|
||||
{3, 80},
|
||||
{5, 60},
|
||||
{99, 60}, // clamped to the last element
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if got := rankFloor(desc, c.rank); got != c.want {
|
||||
t.Errorf("rankFloor(rank=%d) = %d, want %d", c.rank, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := rankFloor(nil, 3); got != 0 {
|
||||
t.Errorf("rankFloor(empty) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTierBudget(t *testing.T) {
|
||||
const aFloor, bFloor = uint32(1000), uint32(100)
|
||||
|
||||
cases := []struct {
|
||||
listens uint32
|
||||
wantTrack, wantRG int
|
||||
}{
|
||||
{2000, perArtistTierATrack, perArtistTierARG}, // tier A
|
||||
{1000, perArtistTierATrack, perArtistTierARG}, // exactly on A floor
|
||||
{500, perArtistTierBTrack, perArtistTierBRG}, // tier B
|
||||
{100, perArtistTierBTrack, perArtistTierBRG}, // exactly on B floor
|
||||
{10, perArtistTierCTrack, perArtistTierCRG}, // tier C
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
gotTrack, gotRG := tierBudget(c.listens, aFloor, bFloor)
|
||||
if gotTrack != c.wantTrack || gotRG != c.wantRG {
|
||||
t.Errorf("tierBudget(%d) = (%d, %d), want (%d, %d)",
|
||||
c.listens, gotTrack, gotRG, c.wantTrack, c.wantRG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mkMBID builds a distinct uuid16 from a single byte, for heap tests.
|
||||
func mkMBID(b byte) uuid16 {
|
||||
var id uuid16
|
||||
|
||||
id[0] = b
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
func TestArtistTopNBoundedAndDeduped(t *testing.T) {
|
||||
a := &artistTopN{n: 3, inSet: make(map[uuid16]struct{})}
|
||||
|
||||
// Add five distinct recordings; only the top 3 by listens survive.
|
||||
for i, listens := range []uint32{10, 50, 30, 5, 40} {
|
||||
a.add(keptRecordingRow{mbid: mkMBID(byte(i + 1)), listens: listens})
|
||||
}
|
||||
|
||||
if len(a.rows) != 3 {
|
||||
t.Fatalf("len = %d, want 3 (bounded)", len(a.rows))
|
||||
}
|
||||
|
||||
got := map[uint32]bool{}
|
||||
for _, r := range a.rows {
|
||||
got[r.listens] = true
|
||||
}
|
||||
|
||||
for _, want := range []uint32{50, 40, 30} {
|
||||
if !got[want] {
|
||||
t.Errorf("expected top listens %d retained, have %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got[10] || got[5] {
|
||||
t.Errorf("evicted entries survived: %v", got)
|
||||
}
|
||||
|
||||
// Re-adding an existing MBID is a no-op, even with a higher count.
|
||||
before := len(a.rows)
|
||||
|
||||
a.add(keptRecordingRow{mbid: mkMBID(2), listens: 9999})
|
||||
|
||||
if len(a.rows) != before {
|
||||
t.Errorf("duplicate MBID grew the set: %d != %d", len(a.rows), before)
|
||||
}
|
||||
|
||||
if _, dupHigh := got[9999]; dupHigh {
|
||||
t.Error("duplicate MBID should not have been re-ranked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistTopRGBoundedAndDeduped(t *testing.T) {
|
||||
a := &artistTopRG{n: 2, inSet: make(map[uuid16]struct{})}
|
||||
|
||||
a.add(mkMBID(1), 100)
|
||||
a.add(mkMBID(2), 200)
|
||||
a.add(mkMBID(3), 50) // below both, dropped
|
||||
a.add(mkMBID(1), 999) // duplicate, ignored
|
||||
|
||||
if len(a.rgs) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(a.rgs))
|
||||
}
|
||||
|
||||
for _, c := range a.rgs {
|
||||
if c.listens == 50 {
|
||||
t.Error("sub-threshold RG was kept")
|
||||
}
|
||||
|
||||
if c.rg == mkMBID(1) && c.listens != 100 {
|
||||
t.Errorf("duplicate RG re-ranked to %d", c.listens)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// sparkFixtureRow mimics the real spark listens schema: the aggregator
|
||||
// must project just recording/release/artist MBIDs out of it.
|
||||
type sparkFixtureRow struct {
|
||||
ListenedAt int64 `parquet:"listened_at"`
|
||||
UserID int64 `parquet:"user_id"`
|
||||
ArtistName string `parquet:"artist_name,optional"`
|
||||
RecordingMBID string `parquet:"recording_mbid,optional"`
|
||||
ReleaseMBID string `parquet:"release_mbid,optional"`
|
||||
ArtistMBIDs []string `parquet:"artist_credit_mbids,optional,list"`
|
||||
}
|
||||
|
||||
func makeParquet(t *testing.T, rows []sparkFixtureRow) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w := parquet.NewGenericWriter[sparkFixtureRow](&buf)
|
||||
|
||||
if _, err := w.Write(rows); err != nil {
|
||||
t.Fatalf("parquet write: %v", err)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("parquet close: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func makeTar(t *testing.T, members map[string][]byte, order []string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
for _, name := range order {
|
||||
data := members[name]
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(data)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatalf("tar header: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
t.Fatalf("tar write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("tar close: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func zstdCompress(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
zw, err := zstd.NewWriter(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("zstd writer: %v", err)
|
||||
}
|
||||
|
||||
if _, err := zw.Write(data); err != nil {
|
||||
t.Fatalf("zstd write: %v", err)
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zstd close: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func csvBytes(t *testing.T, rows [][]string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w := csv.NewWriter(&buf)
|
||||
if err := w.WriteAll(rows); err != nil {
|
||||
t.Fatalf("csv write: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// listensOf builds n identical listen rows for a recording.
|
||||
func listensOf(n int, recording, release string, artists []string) []sparkFixtureRow {
|
||||
rows := make([]sparkFixtureRow, n)
|
||||
for i := range rows {
|
||||
rows[i] = sparkFixtureRow{
|
||||
ListenedAt: 1700000000 + int64(i),
|
||||
UserID: int64(i),
|
||||
ArtistName: "Fixture Artist",
|
||||
RecordingMBID: recording,
|
||||
ReleaseMBID: release,
|
||||
ArtistMBIDs: artists,
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// canonicalDataCSV builds a canonical_musicbrainz_data.csv fixture.
|
||||
func canonicalDataCSV(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
rows := [][]string{
|
||||
{
|
||||
"id", "artist_credit_id", "artist_mbids", "artist_credit_name",
|
||||
"release_mbid", "release_name", "recording_mbid", "recording_name",
|
||||
"combined_lookup", "score",
|
||||
},
|
||||
{"1", "10", "{" + artA + "}", "Solo Star", relA, "Big Album", recA, "Hit Song", "x", "1"},
|
||||
{"2", "10", "{" + artA + "}", "Solo Star", relA, "Big Album", recB, "Deep Cut", "x", "1"},
|
||||
{
|
||||
"3", "11", "{" + artA + "," + artB + "}", "Solo Star feat. Guest",
|
||||
relB, "Duet Album", recC, "Duet Song", "x", "1",
|
||||
},
|
||||
}
|
||||
|
||||
return csvBytes(t, rows)
|
||||
}
|
||||
|
||||
// canonicalRedirectCSV builds a canonical_release_redirect.csv fixture.
|
||||
func canonicalRedirectCSV(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
rows := [][]string{
|
||||
{"release_mbid", "canonical_release_mbid", "release_group_mbid"},
|
||||
{relA, relA, rgA},
|
||||
{relB, relB, rgB},
|
||||
}
|
||||
|
||||
return csvBytes(t, rows)
|
||||
}
|
||||
|
||||
// serveDumps returns an httptest server presenting MetaBrainz-style
|
||||
// listing pages and Range-capable dump files.
|
||||
func serveDumps(t *testing.T, sparkTar, canonicalTarZst []byte) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
listensDir = "listenbrainz-dump-1-20260101-000003-full"
|
||||
sparkFile = "listenbrainz-spark-dump-1-20260101-000003-full.tar"
|
||||
canonicalDir = "musicbrainz-canonical-dump-20260101-080003"
|
||||
canonicalTar = "musicbrainz-canonical-dump-20260101-080003.tar.zst"
|
||||
)
|
||||
|
||||
modTime := time.Now()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/listens/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch strings.TrimPrefix(r.URL.Path, "/listens/") {
|
||||
case "":
|
||||
_, _ = fmt.Fprintf(w, `<a href="%s/">%s/</a>`, listensDir, listensDir)
|
||||
case listensDir + "/":
|
||||
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, sparkFile, sparkFile)
|
||||
case listensDir + "/" + sparkFile:
|
||||
http.ServeContent(w, r, sparkFile, modTime, bytes.NewReader(sparkTar))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/canonical/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch strings.TrimPrefix(r.URL.Path, "/canonical/") {
|
||||
case "":
|
||||
_, _ = fmt.Fprintf(w, `<a href="%s/">%s/</a>`, canonicalDir, canonicalDir)
|
||||
case canonicalDir + "/":
|
||||
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, canonicalTar, canonicalTar)
|
||||
case canonicalDir + "/" + canonicalTar:
|
||||
http.ServeContent(w, r, canonicalTar, modTime, bytes.NewReader(canonicalTarZst))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func testImporter(t *testing.T, si *SearchIndex, srv *httptest.Server) *dumpImporter {
|
||||
t.Helper()
|
||||
|
||||
return &dumpImporter{
|
||||
si: si,
|
||||
lb: nil, // patch passes skipped in tests
|
||||
logger: testLogger(),
|
||||
httpClient: srv.Client(),
|
||||
stagingDir: t.TempDir(),
|
||||
canonicalBaseURL: srv.URL + "/canonical/",
|
||||
listensBaseURL: srv.URL + "/listens/",
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureSparkTar(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
// Member 1: recA is popular (12 listens). Member 2: recB has 11,
|
||||
// recC has 12 (multi-artist credit). Totals: artA = 35, artB = 12.
|
||||
member1 := makeParquet(t, listensOf(12, recA, relA, []string{artA}))
|
||||
member2 := makeParquet(t, append(
|
||||
listensOf(11, recB, relA, []string{artA}),
|
||||
listensOf(12, recC, relB, []string{artA, artB})...,
|
||||
))
|
||||
|
||||
prefix := "listenbrainz-spark-dump-1-20260101-000003-full/listens/"
|
||||
|
||||
return makeTar(t,
|
||||
map[string][]byte{
|
||||
prefix + "1.parquet": member1,
|
||||
prefix + "2.parquet": member2,
|
||||
},
|
||||
[]string{prefix + "1.parquet", prefix + "2.parquet"},
|
||||
)
|
||||
}
|
||||
|
||||
func fixtureCanonicalTarZst(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
prefix := "musicbrainz-canonical-dump-20260101-080003/"
|
||||
|
||||
raw := makeTar(t,
|
||||
map[string][]byte{
|
||||
prefix + "canonical_musicbrainz_data.csv": canonicalDataCSV(t),
|
||||
prefix + "canonical_release_redirect.csv": canonicalRedirectCSV(t),
|
||||
prefix + "canonical_recording_redirect.csv": {},
|
||||
},
|
||||
[]string{
|
||||
prefix + "canonical_release_redirect.csv",
|
||||
prefix + "canonical_musicbrainz_data.csv",
|
||||
prefix + "canonical_recording_redirect.csv",
|
||||
},
|
||||
)
|
||||
|
||||
return zstdCompress(t, raw)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAggregateListenCounts(t *testing.T) {
|
||||
srv := serveDumps(t, fixtureSparkTar(t), nil)
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||
imp := testImporter(t, si, srv)
|
||||
|
||||
sparkURL, err := discoverDumpFile(
|
||||
context.Background(), imp.httpClient, imp.listensBaseURL, listensDirRe, sparkFileRe,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("discover: %v", err)
|
||||
}
|
||||
|
||||
st := &countsState{SparkURL: sparkURL}
|
||||
if err := imp.aggregateListenCounts(context.Background(), st); err != nil {
|
||||
t.Fatalf("aggregate: %v", err)
|
||||
}
|
||||
|
||||
assertCount := func(kind byte, mbid string, want uint32) {
|
||||
t.Helper()
|
||||
|
||||
key, ok := makeMBIDKey(kind, mbid)
|
||||
if !ok {
|
||||
t.Fatalf("bad fixture mbid %s", mbid)
|
||||
}
|
||||
|
||||
if got := st.counts[key]; got != want {
|
||||
t.Errorf("count(kind=%d, %s) = %d, want %d", kind, mbid, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
assertCount(countKindRecording, recA, 12)
|
||||
assertCount(countKindRecording, recB, 11)
|
||||
assertCount(countKindRecording, recC, 12)
|
||||
assertCount(countKindRelease, relA, 23)
|
||||
assertCount(countKindRelease, relB, 12)
|
||||
assertCount(countKindArtist, artA, 35)
|
||||
assertCount(countKindArtist, artB, 12)
|
||||
|
||||
if !st.Done {
|
||||
t.Error("state not marked done")
|
||||
}
|
||||
|
||||
// The checkpoint file round-trips.
|
||||
loaded, err := imp.readCountsFile()
|
||||
if err != nil {
|
||||
t.Fatalf("read counts file: %v", err)
|
||||
}
|
||||
|
||||
if loaded == nil || !loaded.Done || len(loaded.counts) != len(st.counts) {
|
||||
t.Fatalf("checkpoint mismatch: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateResumeFromOffset(t *testing.T) {
|
||||
sparkTar := fixtureSparkTar(t)
|
||||
srv := serveDumps(t, sparkTar, nil)
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||
imp := testImporter(t, si, srv)
|
||||
|
||||
sparkURL := srv.URL + "/listens/listenbrainz-dump-1-20260101-000003-full/listenbrainz-spark-dump-1-20260101-000003-full.tar"
|
||||
|
||||
// Full run for reference.
|
||||
full := &countsState{SparkURL: sparkURL}
|
||||
if err := imp.aggregateListenCounts(context.Background(), full); err != nil {
|
||||
t.Fatalf("full aggregate: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a checkpoint taken after member 1: offset = header
|
||||
// block + padded member-1 size (fixture names are short, so the
|
||||
// header is a single 512-byte block).
|
||||
member1 := makeParquet(t, listensOf(12, recA, relA, []string{artA}))
|
||||
offset := int64(512) + (int64(len(member1))+511)/512*512
|
||||
|
||||
key, _ := makeMBIDKey(countKindRecording, recA)
|
||||
relKey, _ := makeMBIDKey(countKindRelease, relA)
|
||||
artKey, _ := makeMBIDKey(countKindArtist, artA)
|
||||
|
||||
resumed := &countsState{
|
||||
SparkURL: sparkURL,
|
||||
Offset: offset,
|
||||
MemberIdx: 1,
|
||||
counts: map[mbidKey]uint32{
|
||||
key: 12,
|
||||
relKey: 12,
|
||||
artKey: 12,
|
||||
},
|
||||
}
|
||||
|
||||
if err := imp.aggregateListenCounts(context.Background(), resumed); err != nil {
|
||||
t.Fatalf("resumed aggregate: %v", err)
|
||||
}
|
||||
|
||||
if len(resumed.counts) != len(full.counts) {
|
||||
t.Fatalf("resumed entities = %d, want %d", len(resumed.counts), len(full.counts))
|
||||
}
|
||||
|
||||
for k, want := range full.counts {
|
||||
if got := resumed.counts[k]; got != want {
|
||||
t.Errorf("resumed count %s = %d, want %d (double count?)", formatUUID(k[1:]), got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumableReaderReconnects(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64KB
|
||||
|
||||
// A flaky server that truncates every response to 10KB, forcing
|
||||
// the reader to reconnect with Range requests.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := int64(0)
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
_, _ = fmt.Sscanf(rng, "bytes=%d-", &offset)
|
||||
}
|
||||
|
||||
chunk := payload[offset:min(offset+10240, int64(len(payload)))]
|
||||
|
||||
w.Header().Set("Content-Range",
|
||||
fmt.Sprintf("bytes %d-%d/%d", offset, offset+int64(len(chunk))-1, len(payload)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write(chunk)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
r := newResumableReader(context.Background(), srv.Client(), srv.URL, 0)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("payload mismatch: got %d bytes, want %d", len(got), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDumpImportEndToEnd(t *testing.T) {
|
||||
srv := serveDumps(t, fixtureSparkTar(t), fixtureCanonicalTarZst(t))
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||
imp := testImporter(t, si, srv)
|
||||
stagingDir := imp.stagingDir
|
||||
|
||||
// A legacy API-crawled row with inflated popularity must be
|
||||
// cleared by the first dump import (scale consistency).
|
||||
legacyMBID := "99999999-9999-9999-9999-999999999999"
|
||||
|
||||
si.upsertBatch([]SearchIndexResult{{
|
||||
EntityType: "recording",
|
||||
MBID: legacyMBID,
|
||||
Title: "Legacy Row",
|
||||
ArtistName: "Old Crawl",
|
||||
ArtistMBID: artA,
|
||||
Popularity: 123_456_789,
|
||||
}})
|
||||
|
||||
if err := imp.run(context.Background()); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
legacyRows, err := db.QueryContext(
|
||||
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", legacyMBID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy query: %v", err)
|
||||
}
|
||||
|
||||
if legacyRows.Next() {
|
||||
var n int
|
||||
|
||||
_ = legacyRows.Scan(&n)
|
||||
|
||||
if n != 0 {
|
||||
t.Error("legacy API-crawled row survived the first dump import")
|
||||
}
|
||||
}
|
||||
|
||||
_ = legacyRows.Close()
|
||||
|
||||
// Index rows landed with dump-derived popularity.
|
||||
assertRow := func(mbid, entityType, title string, popularity int) {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.QueryContext(
|
||||
"SELECT title, popularity FROM explore_index WHERE mbid = ? AND entity_type = ?",
|
||||
mbid, entityType,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
t.Fatalf("no %s row for %s", entityType, mbid)
|
||||
}
|
||||
|
||||
var gotTitle string
|
||||
|
||||
var gotPop int
|
||||
|
||||
if err := rows.Scan(&gotTitle, &gotPop); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
if gotTitle != title || gotPop != popularity {
|
||||
t.Errorf("%s %s = (%q, %d), want (%q, %d)",
|
||||
entityType, mbid, gotTitle, gotPop, title, popularity)
|
||||
}
|
||||
}
|
||||
|
||||
assertRow(recA, "recording", "Hit Song", 12)
|
||||
assertRow(recB, "recording", "Deep Cut", 11)
|
||||
assertRow(recC, "recording", "Duet Song", 12)
|
||||
assertRow(rgA, "release_group", "Big Album", 23)
|
||||
assertRow(rgB, "release_group", "Duet Album", 12)
|
||||
assertRow(artA, "artist", "Solo Star", 35)
|
||||
|
||||
// artB only ever appears in a multi-artist credit: no name is
|
||||
// derivable from the dump, so it must be queued for the API
|
||||
// metadata patch instead of being written nameless.
|
||||
rows, err := db.QueryContext(
|
||||
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", artB,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("query artB: %v", err)
|
||||
}
|
||||
|
||||
if rows.Next() {
|
||||
var n int
|
||||
|
||||
_ = rows.Scan(&n)
|
||||
|
||||
if n != 0 {
|
||||
t.Errorf("artB row written without a name source")
|
||||
}
|
||||
}
|
||||
|
||||
_ = rows.Close()
|
||||
|
||||
found := false
|
||||
|
||||
for _, mbid := range imp.pendingArtists {
|
||||
if mbid == artB {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("artB not queued for metadata patch: %v", imp.pendingArtists)
|
||||
}
|
||||
|
||||
// FTS search works end to end.
|
||||
si.MarkReadyIfPopulated()
|
||||
|
||||
results := si.Search(context.Background(), "hit song", 10)
|
||||
if len(results) == 0 || results[0].MBID != recA {
|
||||
t.Fatalf("search for indexed recording failed: %+v", results)
|
||||
}
|
||||
|
||||
// Completion recorded; staging cleaned up.
|
||||
if !si.hasMeta(dumpImportDoneKey) {
|
||||
t.Error("dump_import_done not recorded")
|
||||
}
|
||||
|
||||
// The incremental refresh baseline was recorded, and the
|
||||
// release→release-group map was persisted for future rollups.
|
||||
if _, ok := si.metaInt(listensAppliedSeriesKey); !ok {
|
||||
t.Error("listens_applied_series baseline not recorded")
|
||||
}
|
||||
|
||||
var relToRGRows int
|
||||
|
||||
rtrRows, err := db.QueryContext("SELECT COUNT(*) FROM release_to_rg")
|
||||
if err != nil {
|
||||
t.Fatalf("query release_to_rg: %v", err)
|
||||
}
|
||||
|
||||
if rtrRows.Next() {
|
||||
_ = rtrRows.Scan(&relToRGRows)
|
||||
}
|
||||
|
||||
_ = rtrRows.Close()
|
||||
|
||||
if relToRGRows == 0 {
|
||||
t.Error("release_to_rg not populated after import")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
|
||||
t.Errorf("staging dir not cleaned up: %v", err)
|
||||
}
|
||||
|
||||
// Re-running is a cheap no-op that doesn't error.
|
||||
imp2 := testImporter(t, si, srv)
|
||||
if err := imp2.run(context.Background()); err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpImportResumesAfterCancel(t *testing.T) {
|
||||
srv := serveDumps(t, fixtureSparkTar(t), fixtureCanonicalTarZst(t))
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||
imp := testImporter(t, si, srv)
|
||||
|
||||
// Cancelled before it can start streaming: no partial state may
|
||||
// break the follow-up run.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if err := imp.run(ctx); err == nil {
|
||||
t.Fatal("cancelled run should return an error")
|
||||
}
|
||||
|
||||
if err := imp.run(context.Background()); err != nil {
|
||||
t.Fatalf("rerun after cancel: %v", err)
|
||||
}
|
||||
|
||||
results := si.Search(context.Background(), "hit song", 10)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("index empty after resumed run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFreeDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := checkFreeDisk(dir, 1); err != nil {
|
||||
t.Errorf("1 byte requirement should pass: %v", err)
|
||||
}
|
||||
|
||||
if err := checkFreeDisk(dir, 1<<62); err == nil {
|
||||
t.Error("absurd requirement should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverDumpFilePickNewest(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
_, _ = io.WriteString(w, `
|
||||
<a href="musicbrainz-canonical-dump-20260101-080003/">old</a>
|
||||
<a href="musicbrainz-canonical-dump-20260615-080003/">new</a>
|
||||
<a href="unrelated-dir/">x</a>`)
|
||||
case "/musicbrainz-canonical-dump-20260615-080003/":
|
||||
_, _ = io.WriteString(w,
|
||||
`<a href="musicbrainz-canonical-dump-20260615-080003.tar.zst">f</a>`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
url, err := discoverDumpFile(
|
||||
context.Background(), srv.Client(), srv.URL+"/", canonicalDirRe, canonicalFileRe,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("discover: %v", err)
|
||||
}
|
||||
|
||||
want := srv.URL + "/musicbrainz-canonical-dump-20260615-080003/musicbrainz-canonical-dump-20260615-080003.tar.zst"
|
||||
if url != want {
|
||||
t.Errorf("url = %s, want %s", url, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerCountUpdateDoesNotTouchPopularity(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||
|
||||
si.upsertBatch([]SearchIndexResult{{
|
||||
EntityType: "recording",
|
||||
MBID: recA,
|
||||
Title: "Hit Song",
|
||||
ArtistName: "Solo Star",
|
||||
ArtistMBID: artA,
|
||||
Popularity: 12,
|
||||
}})
|
||||
|
||||
updated := si.updateListenerCounts(map[string]PopularityData{
|
||||
recA: {ListenCount: 999_999, ListenerCount: 42},
|
||||
})
|
||||
if updated != 1 {
|
||||
t.Fatalf("updated = %d, want 1", updated)
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(
|
||||
"SELECT popularity, listener_count FROM explore_index WHERE mbid = ?", recA,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
t.Fatal("row missing")
|
||||
}
|
||||
|
||||
var pop, listeners int
|
||||
|
||||
if err := rows.Scan(&pop, &listeners); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
if pop != 12 {
|
||||
t.Errorf("popularity = %d, want 12 (dump scale must stay authoritative)", pop)
|
||||
}
|
||||
|
||||
if listeners != 42 {
|
||||
t.Errorf("listener_count = %d, want 42", listeners)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Incremental listen-count refresh. ListenBrainz publishes a small
|
||||
// (~250MB) incremental spark dump every day containing only the listens
|
||||
// submitted since the previous dump, in the same parquet-in-tar format
|
||||
// as the full dump. This folds those daily deltas into the index's
|
||||
// popularity numbers additively, so recordings, artists, and albums stay
|
||||
// fresh without re-streaming the ~170GB full dump and without a single
|
||||
// ListenBrainz API call.
|
||||
//
|
||||
// Album (release-group) popularity is derived locally: the incremental
|
||||
// gives per-release listen counts, which are rolled up to their release
|
||||
// group via the release_to_rg map captured during the full import.
|
||||
//
|
||||
// Correctness: each dump's deltas plus the high-water-mark advance are
|
||||
// applied in one transaction, so a crash can't half-apply a dump, and
|
||||
// the high-water-mark guarantees each dump is applied at most once. The
|
||||
// sum of the full dump plus every incremental is therefore the exact
|
||||
// cumulative listen count — the additive model does not drift.
|
||||
|
||||
const (
|
||||
// defaultIncrementalBaseURL is where ListenBrainz publishes daily
|
||||
// incremental listen dumps.
|
||||
defaultIncrementalBaseURL = "https://data.metabrainz.org/pub/musicbrainz/listenbrainz/incremental/"
|
||||
|
||||
// listensCatchupTsKey records when the incremental refresh last ran
|
||||
// (RFC3339), gating how often it re-checks for new dumps.
|
||||
listensCatchupTsKey = "listens_last_catchup"
|
||||
|
||||
// listensCatchupInterval is the default minimum time between refresh
|
||||
// checks. Album/track popularity is slow-moving and each dump is a
|
||||
// ~250MB download, so a weekly cadence keeps the index current while
|
||||
// bounding background network use.
|
||||
listensCatchupInterval = 7 * 24 * time.Hour
|
||||
|
||||
// deltaInsertBatch bounds rows per multi-row INSERT into the temp
|
||||
// delta table during a single incremental apply.
|
||||
deltaInsertBatch = 500
|
||||
|
||||
// releaseLookupBatch bounds release MBIDs per release_to_rg lookup.
|
||||
releaseLookupBatch = 500
|
||||
)
|
||||
|
||||
var (
|
||||
incrementalDirRe = regexp.MustCompile(`^listenbrainz-dump-\d+-\d{8}-\d+-incremental$`)
|
||||
incrementalFileRe = regexp.MustCompile(`^listenbrainz-spark-dump-.*-incremental\.tar$`)
|
||||
)
|
||||
|
||||
// incrementalDump identifies one daily incremental dump.
|
||||
type incrementalDump struct {
|
||||
series int
|
||||
url string
|
||||
}
|
||||
|
||||
// RefreshListenCounts folds any incremental dumps newer than the current
|
||||
// high-water-mark into the index's popularity numbers. It is a no-op
|
||||
// when there is no completed baseline import, when a full build is
|
||||
// running, when the last refresh was within minInterval (pass 0 to
|
||||
// force), or when offline. Runs synchronously — callers wanting the
|
||||
// background behaviour should invoke it in a goroutine.
|
||||
func (si *SearchIndex) RefreshListenCounts(ctx context.Context, minInterval time.Duration) {
|
||||
if !si.hasMeta(dumpImportDoneKey) {
|
||||
si.logger.Info("incremental refresh: no baseline import yet, skipping")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
si.mu.RLock()
|
||||
building := si.cancel != nil
|
||||
si.mu.RUnlock()
|
||||
|
||||
if building {
|
||||
si.logger.Info("incremental refresh: full build running, skipping")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if minInterval > 0 && si.refreshedWithin(minInterval) {
|
||||
si.logger.Info("incremental refresh: checked recently, skipping")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hwm, ok := si.metaInt(listensAppliedSeriesKey)
|
||||
if !ok {
|
||||
si.logger.Warn("incremental refresh: no baseline series recorded, skipping")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
dumps, err := discoverIncrementalDumps(ctx, client, defaultIncrementalBaseURL, hwm)
|
||||
if err != nil {
|
||||
si.logger.Warn("incremental refresh: discovery failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Record the check even when there is nothing to apply, so the
|
||||
// cadence gate holds regardless of outcome.
|
||||
defer si.setMeta(listensCatchupTsKey, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
if len(dumps) == 0 {
|
||||
si.logger.Info("incremental refresh: up to date", "throughSeries", hwm)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
si.logger.Info("incremental refresh: applying dumps",
|
||||
"count", len(dumps), "fromSeries", hwm+1, "toSeries", dumps[len(dumps)-1].series,
|
||||
)
|
||||
|
||||
applied := 0
|
||||
through := hwm
|
||||
|
||||
for _, d := range dumps {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if err := si.applyIncremental(ctx, client, d); err != nil {
|
||||
// Stop at the first failure; the high-water-mark is only
|
||||
// advanced on success, so the next run resumes from here.
|
||||
si.logger.Warn("incremental refresh: apply failed, stopping",
|
||||
"series", d.series, "error", err)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
applied++
|
||||
through = d.series
|
||||
}
|
||||
|
||||
if applied > 0 {
|
||||
// Popularity changed, so refresh the champion tier that backs
|
||||
// generic short-prefix searches.
|
||||
si.scheduleChampionRebuild()
|
||||
}
|
||||
|
||||
si.logger.Info("incremental refresh: complete", "applied", applied, "throughSeries", through)
|
||||
}
|
||||
|
||||
// applyIncremental downloads one incremental dump, aggregates its listen
|
||||
// counts, rolls release counts up to release groups, and applies the
|
||||
// deltas atomically together with the high-water-mark advance.
|
||||
func (si *SearchIndex) applyIncremental(
|
||||
ctx context.Context, client *http.Client, dump incrementalDump,
|
||||
) error {
|
||||
start := time.Now()
|
||||
|
||||
stream := newResumableReader(ctx, client, dump.url, 0)
|
||||
|
||||
defer func() { _ = stream.Close() }()
|
||||
|
||||
counts, err := aggregateTarListens(ctx, stream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aggregate incremental %d: %w", dump.series, err)
|
||||
}
|
||||
|
||||
rec, art, rel := splitCountsByKind(counts)
|
||||
rg := si.rollupReleaseDeltas(rel)
|
||||
|
||||
if err := si.commitListenDeltas(dump.series, rec, art, rg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
si.logger.Info("incremental refresh: applied dump",
|
||||
"series", dump.series,
|
||||
"recordings", len(rec),
|
||||
"artists", len(art),
|
||||
"releaseGroups", len(rg),
|
||||
"elapsed", time.Since(start).Round(time.Millisecond),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitCountsByKind separates an aggregated counts map into per-kind
|
||||
// maps of canonical MBID string → delta.
|
||||
func splitCountsByKind(counts map[mbidKey]uint32) (rec, art, rel map[string]uint32) {
|
||||
rec = make(map[string]uint32)
|
||||
art = make(map[string]uint32)
|
||||
rel = make(map[string]uint32)
|
||||
|
||||
for k, v := range counts {
|
||||
mbid := formatUUID(k[1:])
|
||||
|
||||
switch k[0] {
|
||||
case countKindRecording:
|
||||
rec[mbid] += v
|
||||
case countKindArtist:
|
||||
art[mbid] += v
|
||||
case countKindRelease:
|
||||
rel[mbid] += v
|
||||
}
|
||||
}
|
||||
|
||||
return rec, art, rel
|
||||
}
|
||||
|
||||
// rollupReleaseDeltas maps per-release listen deltas to their release
|
||||
// group via the release_to_rg table and sums per group. Releases not in
|
||||
// the table (below the index floor, or unknown) contribute nothing.
|
||||
func (si *SearchIndex) rollupReleaseDeltas(rel map[string]uint32) map[string]uint32 {
|
||||
rg := make(map[string]uint32)
|
||||
if len(rel) == 0 {
|
||||
return rg
|
||||
}
|
||||
|
||||
mbids := make([]string, 0, len(rel))
|
||||
for m := range rel {
|
||||
mbids = append(mbids, m)
|
||||
}
|
||||
|
||||
for i := 0; i < len(mbids); i += releaseLookupBatch {
|
||||
end := min(i+releaseLookupBatch, len(mbids))
|
||||
batch := mbids[i:end]
|
||||
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
|
||||
|
||||
args := make([]any, len(batch))
|
||||
for j, m := range batch {
|
||||
args[j] = m
|
||||
}
|
||||
|
||||
rows, err := si.db.QueryContext(
|
||||
"SELECT release_mbid, rg_mbid FROM release_to_rg WHERE release_mbid IN ("+placeholders+")",
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
si.logger.Warn("incremental refresh: release_to_rg lookup failed", "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var relMBID, rgMBID string
|
||||
if err := rows.Scan(&relMBID, &rgMBID); err == nil {
|
||||
rg[rgMBID] += rel[relMBID]
|
||||
}
|
||||
}
|
||||
|
||||
_ = rows.Close()
|
||||
}
|
||||
|
||||
return rg
|
||||
}
|
||||
|
||||
// commitListenDeltas applies recording, artist, and release-group deltas
|
||||
// to explore_index and advances the high-water-mark to series, all in a
|
||||
// single transaction so the apply is crash-atomic and exactly-once.
|
||||
func (si *SearchIndex) commitListenDeltas(
|
||||
series int, rec, art, rg map[string]uint32,
|
||||
) error {
|
||||
tx, err := si.db.BeginTx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("incremental tx: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"CREATE TEMP TABLE IF NOT EXISTS incr_delta (mbid TEXT, kind TEXT, delta INTEGER)",
|
||||
); err != nil {
|
||||
return fmt.Errorf("incremental temp table: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("DELETE FROM incr_delta"); err != nil {
|
||||
return fmt.Errorf("incremental temp reset: %w", err)
|
||||
}
|
||||
|
||||
for _, kd := range []struct {
|
||||
kind string
|
||||
deltas map[string]uint32
|
||||
}{
|
||||
{"recording", rec},
|
||||
{"artist", art},
|
||||
{"release_group", rg},
|
||||
} {
|
||||
if err := insertDeltas(tx, kd.kind, kd.deltas); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Additive apply: bump popularity for every index row that has a
|
||||
// matching delta. Rows with no delta are untouched; deltas with no
|
||||
// matching row (entity not indexed) are ignored.
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE explore_index
|
||||
SET popularity = popularity + d.delta
|
||||
FROM incr_delta d
|
||||
WHERE d.mbid = explore_index.mbid
|
||||
AND d.kind = explore_index.entity_type
|
||||
`); err != nil {
|
||||
return fmt.Errorf("incremental apply: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("DELETE FROM incr_delta"); err != nil {
|
||||
return fmt.Errorf("incremental temp cleanup: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"INSERT OR REPLACE INTO explore_index_meta (key, value) VALUES (?, ?)",
|
||||
listensAppliedSeriesKey, strconv.Itoa(series),
|
||||
); err != nil {
|
||||
return fmt.Errorf("incremental advance high-water-mark: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("incremental commit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertDeltas bulk-inserts a kind's deltas into the temp table.
|
||||
func insertDeltas(tx *sql.Tx, kind string, deltas map[string]uint32) error {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rowArgs := make([]any, 0, deltaInsertBatch*3)
|
||||
pending := 0
|
||||
|
||||
flush := func() error {
|
||||
if pending == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := strings.TrimSuffix(strings.Repeat("(?,?,?),", pending), ",")
|
||||
query := "INSERT INTO incr_delta (mbid, kind, delta) VALUES " + values
|
||||
|
||||
if _, err := tx.Exec(query, rowArgs...); err != nil {
|
||||
return fmt.Errorf("incremental insert deltas: %w", err)
|
||||
}
|
||||
|
||||
rowArgs = rowArgs[:0]
|
||||
pending = 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for mbid, d := range deltas {
|
||||
rowArgs = append(rowArgs, mbid, kind, int64(d))
|
||||
pending++
|
||||
|
||||
if pending >= deltaInsertBatch {
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flush()
|
||||
}
|
||||
|
||||
// aggregateTarListens streams a tar of parquet listen members and sums
|
||||
// the per-entity listen counts. Unlike the full-dump path, the whole
|
||||
// (small) incremental is aggregated in RAM with no checkpointing.
|
||||
func aggregateTarListens(ctx context.Context, r io.Reader) (map[mbidKey]uint32, error) {
|
||||
buffered := bufio.NewReaderSize(r, 1<<20)
|
||||
tr := tar.NewReader(buffered)
|
||||
counts := make(map[mbidKey]uint32, 1<<18)
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("incremental tar: %w", err)
|
||||
}
|
||||
|
||||
if hdr.Typeflag != tar.TypeReg || !strings.HasSuffix(hdr.Name, ".parquet") {
|
||||
continue
|
||||
}
|
||||
|
||||
if hdr.Size > maxParquetMemberSize {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: parquet member %s is %d bytes", ErrDumpFormat, hdr.Name, hdr.Size,
|
||||
)
|
||||
}
|
||||
|
||||
buf := make([]byte, hdr.Size)
|
||||
if _, err := io.ReadFull(tr, buf); err != nil {
|
||||
return nil, fmt.Errorf("incremental member read: %w", err)
|
||||
}
|
||||
|
||||
deltas, err := parseListenParquet(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("incremental parse: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range deltas {
|
||||
counts[k] += v
|
||||
}
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// discoverIncrementalDumps lists the incremental directory and returns
|
||||
// the spark-dump URLs for every dump with a series greater than
|
||||
// sinceSeries, sorted ascending so they apply in chronological order.
|
||||
func discoverIncrementalDumps(
|
||||
ctx context.Context, client *http.Client, baseURL string, sinceSeries int,
|
||||
) ([]incrementalDump, error) {
|
||||
hrefs, err := listHrefs(ctx, client, baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dumps []incrementalDump
|
||||
|
||||
for _, h := range hrefs {
|
||||
dir := trimTrailingSlash(h)
|
||||
if !incrementalDirRe.MatchString(dir) {
|
||||
continue
|
||||
}
|
||||
|
||||
series, ok := parseDumpSeries(dir)
|
||||
if !ok || series <= sinceSeries {
|
||||
continue
|
||||
}
|
||||
|
||||
dirURL := baseURL + dir + "/"
|
||||
|
||||
files, err := listHrefs(ctx, client, dirURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if incrementalFileRe.MatchString(f) {
|
||||
dumps = append(dumps, incrementalDump{series: series, url: dirURL + f})
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(dumps, func(i, j int) bool { return dumps[i].series < dumps[j].series })
|
||||
|
||||
return dumps, nil
|
||||
}
|
||||
|
||||
// metaInt reads an integer-valued explore_index_meta key.
|
||||
func (si *SearchIndex) metaInt(key string) (int, bool) {
|
||||
rows, err := si.db.QueryContext("SELECT value FROM explore_index_meta WHERE key = ?", key)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return n, true
|
||||
}
|
||||
|
||||
// refreshedWithin reports whether the incremental refresh last ran less
|
||||
// than d ago.
|
||||
func (si *SearchIndex) refreshedWithin(d time.Duration) bool {
|
||||
rows, err := si.db.QueryContext(
|
||||
"SELECT value FROM explore_index_meta WHERE key = ?", listensCatchupTsKey,
|
||||
)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
return false
|
||||
}
|
||||
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(t) < d
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
func TestParseDumpSeries(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"https://x/fullexport/listenbrainz-dump-2593-20260712-000004-full/y.tar", 2593, true},
|
||||
{"listenbrainz-dump-2594-20260713-000003-incremental", 2594, true},
|
||||
{"listenbrainz-spark-dump-2603-20260722-000003-incremental.tar", 2603, true},
|
||||
{"nothing-here", 0, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, ok := parseDumpSeries(c.in)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("parseDumpSeries(%q) = (%d, %v); want (%d, %v)", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func popularityOf(t *testing.T, db *database.DB, mbid string) (int, bool) {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.QueryContext(
|
||||
"SELECT popularity FROM explore_index WHERE mbid = ?", mbid,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("query popularity: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var pop int
|
||||
if err := rows.Scan(&pop); err != nil {
|
||||
t.Fatalf("scan popularity: %v", err)
|
||||
}
|
||||
|
||||
return pop, true
|
||||
}
|
||||
|
||||
// applyTar runs the incremental split → rollup → commit path against an
|
||||
// in-memory tar, mirroring applyIncremental without the HTTP stream.
|
||||
func applyTar(t *testing.T, si *SearchIndex, series int, tarBytes []byte) {
|
||||
t.Helper()
|
||||
|
||||
counts, err := aggregateTarListens(context.Background(), bytes.NewReader(tarBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("aggregate: %v", err)
|
||||
}
|
||||
|
||||
rec, art, rel := splitCountsByKind(counts)
|
||||
rg := si.rollupReleaseDeltas(rel)
|
||||
|
||||
if err := si.commitListenDeltas(series, rec, art, rg); err != nil {
|
||||
t.Fatalf("commit series %d: %v", series, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementalApplyAdditive(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||
|
||||
si.upsertBatch([]SearchIndexResult{
|
||||
{EntityType: "recording", MBID: recA, Title: "Rec A", Popularity: 100},
|
||||
{EntityType: "artist", MBID: artA, Title: "Art A", Popularity: 100},
|
||||
{EntityType: "release_group", MBID: rgA, Title: "RG A", Popularity: 100},
|
||||
})
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO release_to_rg (release_mbid, rg_mbid) VALUES (?, ?)", relA, rgA,
|
||||
); err != nil {
|
||||
t.Fatalf("seed release_to_rg: %v", err)
|
||||
}
|
||||
|
||||
// 5 listens of recA on relA credited to artA.
|
||||
tarBytes := makeTar(t,
|
||||
map[string][]byte{
|
||||
"listens/1.parquet": makeParquet(t, listensOf(5, recA, relA, []string{artA})),
|
||||
},
|
||||
[]string{"listens/1.parquet"},
|
||||
)
|
||||
|
||||
applyTar(t, si, 2, tarBytes)
|
||||
|
||||
for _, c := range []struct {
|
||||
mbid string
|
||||
want int
|
||||
}{{recA, 105}, {artA, 105}, {rgA, 105}} {
|
||||
if got, ok := popularityOf(t, db, c.mbid); !ok || got != c.want {
|
||||
t.Errorf("popularity(%s) = %d (present=%v); want %d", c.mbid, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if hwm, ok := si.metaInt(listensAppliedSeriesKey); !ok || hwm != 2 {
|
||||
t.Errorf("high-water-mark = %d (present=%v); want 2", hwm, ok)
|
||||
}
|
||||
|
||||
// A second, later dump accumulates additively.
|
||||
applyTar(t, si, 3, tarBytes)
|
||||
|
||||
if got, _ := popularityOf(t, db, recA); got != 110 {
|
||||
t.Errorf("popularity(recA) after second dump = %d; want 110", got)
|
||||
}
|
||||
|
||||
if hwm, _ := si.metaInt(listensAppliedSeriesKey); hwm != 3 {
|
||||
t.Errorf("high-water-mark after second dump = %d; want 3", hwm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementalIgnoresUnknownAndUnmapped(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, testLogger())
|
||||
|
||||
// Only recA is indexed; relA has no release_to_rg mapping.
|
||||
si.upsertBatch([]SearchIndexResult{
|
||||
{EntityType: "recording", MBID: recA, Title: "Rec A", Popularity: 100},
|
||||
})
|
||||
|
||||
// Listens for recB (not indexed) on relB (unmapped) credited to
|
||||
// artB (not indexed). Nothing should change and no row created.
|
||||
tarBytes := makeTar(t,
|
||||
map[string][]byte{
|
||||
"listens/1.parquet": makeParquet(t, listensOf(7, recB, relB, []string{artB})),
|
||||
},
|
||||
[]string{"listens/1.parquet"},
|
||||
)
|
||||
|
||||
applyTar(t, si, 5, tarBytes)
|
||||
|
||||
if _, ok := popularityOf(t, db, recB); ok {
|
||||
t.Error("recB should not have been inserted into the index")
|
||||
}
|
||||
|
||||
if got, _ := popularityOf(t, db, recA); got != 100 {
|
||||
t.Errorf("popularity(recA) = %d; want 100 (untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverIncrementalDumps(t *testing.T) {
|
||||
series := []string{"2594", "2595", "2596"}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/incremental/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only the base listing (exact path); dir listings are handled
|
||||
// by their own more-specific patterns below.
|
||||
if r.URL.Path != "/incremental/" {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, s := range series {
|
||||
_, _ = fmt.Fprintf(w,
|
||||
`<a href="listenbrainz-dump-%s-20260713-000003-incremental/">dir</a>`+"\n", s,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
for _, s := range series {
|
||||
dir := fmt.Sprintf("/incremental/listenbrainz-dump-%s-20260713-000003-incremental/", s)
|
||||
file := fmt.Sprintf("listenbrainz-spark-dump-%s-20260713-000003-incremental.tar", s)
|
||||
|
||||
mux.HandleFunc(dir, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `<a href="%s">file</a>`, file)
|
||||
})
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
dumps, err := discoverIncrementalDumps(
|
||||
context.Background(), srv.Client(), srv.URL+"/incremental/", 2594,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("discover: %v", err)
|
||||
}
|
||||
|
||||
// Only 2595 and 2596 are newer than the 2594 high-water-mark.
|
||||
if len(dumps) != 2 {
|
||||
t.Fatalf("got %d dumps; want 2: %+v", len(dumps), dumps)
|
||||
}
|
||||
|
||||
if dumps[0].series != 2595 || dumps[1].series != 2596 {
|
||||
t.Errorf("series order wrong: %d, %d; want 2595, 2596", dumps[0].series, dumps[1].series)
|
||||
}
|
||||
|
||||
if !strings.Contains(dumps[0].url, "spark-dump-2595") {
|
||||
t.Errorf("unexpected url: %s", dumps[0].url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Stage 4 of the dump import: small, idempotent API patch passes that
|
||||
// fill in what the dumps can't provide. All calls go through the
|
||||
// shared rate-limited ListenBrainz client and its HTTP cache, so
|
||||
// re-running after an interruption is cheap.
|
||||
|
||||
const (
|
||||
// Listener-count patch budgets: only the most popular rows get
|
||||
// listener counts (a secondary ranking signal). 1000 MBIDs per
|
||||
// batched POST call → ~350 API calls total.
|
||||
listenerPatchArtists = 50_000
|
||||
listenerPatchRGs = 100_000
|
||||
listenerPatchRecordings = 200_000
|
||||
|
||||
// popularityBatchSize is the number of MBIDs per LB popularity
|
||||
// or metadata request. LB accepts up to 1000 per call.
|
||||
popularityBatchSize = 1000
|
||||
)
|
||||
|
||||
// runPatchPasses fills listener counts, artist metadata, and the
|
||||
// similar-artist map from the ListenBrainz API.
|
||||
func (imp *dumpImporter) runPatchPasses(ctx context.Context) {
|
||||
if imp.lb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
imp.patchArtistMetadata(ctx)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
imp.patchSimilarArtists(ctx)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
imp.patchListenerCounts(ctx)
|
||||
}
|
||||
|
||||
// patchArtistMetadata batch-fetches type/country/name for indexed
|
||||
// artists that are missing them. Also creates rows for kept artists
|
||||
// whose name wasn't derivable from the canonical dump (multi-artist
|
||||
// credits only).
|
||||
func (imp *dumpImporter) patchArtistMetadata(ctx context.Context) {
|
||||
rows, err := imp.si.db.QueryContext(`
|
||||
SELECT mbid FROM explore_index
|
||||
WHERE entity_type = 'artist'
|
||||
AND (artist_type = '' OR country = '' OR title = '' OR title = mbid)
|
||||
`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var mbids []string
|
||||
|
||||
for rows.Next() {
|
||||
var m string
|
||||
if err := rows.Scan(&m); err == nil {
|
||||
mbids = append(mbids, m)
|
||||
}
|
||||
}
|
||||
|
||||
_ = rows.Close()
|
||||
|
||||
if len(imp.pendingArtists) > 0 {
|
||||
mbids = append(mbids, imp.pendingArtists...)
|
||||
}
|
||||
|
||||
if len(mbids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: patching artist metadata", "artists", len(mbids))
|
||||
|
||||
batches := chunkStrings(mbids, popularityBatchSize)
|
||||
patched := 0
|
||||
|
||||
for i, batch := range batches {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := imp.lb.BatchArtistMetadata(ctx, batch)
|
||||
if err != nil || len(meta) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
entries := make([]SearchIndexResult, 0, len(meta))
|
||||
|
||||
for mbid, m := range meta {
|
||||
if m.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, SearchIndexResult{
|
||||
EntityType: "artist",
|
||||
MBID: mbid,
|
||||
Title: m.Name,
|
||||
ArtistName: m.Name,
|
||||
ArtistMBID: mbid,
|
||||
ArtistType: m.Type,
|
||||
Country: m.Country,
|
||||
})
|
||||
|
||||
// Pre-populate the MB rels cache so on-demand artist
|
||||
// image resolution skips a MusicBrainz call.
|
||||
if imp.si.artistImg != nil {
|
||||
imp.si.artistImg.PreloadArtistRels(mbid, m)
|
||||
}
|
||||
}
|
||||
|
||||
imp.si.upsertBatch(entries)
|
||||
|
||||
patched += len(entries)
|
||||
|
||||
imp.setStageProgress(dumpStagePatch, i+1, len(batches))
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: artist metadata patched", "artists", patched)
|
||||
}
|
||||
|
||||
// patchSimilarArtists refreshes the similar-artist map for library
|
||||
// artists (one API call per library artist, cached for a week).
|
||||
func (imp *dumpImporter) patchSimilarArtists(ctx context.Context) {
|
||||
libraryMBIDs := imp.si.getLibraryArtistMBIDs()
|
||||
if len(libraryMBIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < len(libraryMBIDs); i += similarArtistsBatchSize {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
end := min(i+similarArtistsBatchSize, len(libraryMBIDs))
|
||||
|
||||
grouped := imp.si.fetchSimilarArtistsBatch(ctx, imp.lb, libraryMBIDs[i:end])
|
||||
for seed, similar := range grouped {
|
||||
imp.si.storeSimilarArtists(seed, similar)
|
||||
|
||||
// Flag indexed similar artists for personalized ranking.
|
||||
for _, s := range similar {
|
||||
_, _ = imp.si.db.ExecContext(
|
||||
"UPDATE explore_index SET is_similar = 1 WHERE artist_mbid = ?",
|
||||
s.ArtistMBID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: similar artists patched", "libraryArtists", len(libraryMBIDs))
|
||||
}
|
||||
|
||||
// patchListenerCounts fills listener_count for the most popular rows
|
||||
// of each entity type. Popularity (listen count) is NOT overwritten —
|
||||
// the dump-derived counts stay authoritative so the ranking scale is
|
||||
// consistent across the whole index.
|
||||
func (imp *dumpImporter) patchListenerCounts(ctx context.Context) {
|
||||
kinds := []struct {
|
||||
entityType string
|
||||
limit int
|
||||
fetch func(context.Context, []string) (map[string]PopularityData, error)
|
||||
}{
|
||||
{"artist", listenerPatchArtists, imp.lb.ArtistPopularity},
|
||||
{"release_group", listenerPatchRGs, imp.lb.ReleaseGroupPopularity},
|
||||
{"recording", listenerPatchRecordings, imp.lb.RecordingPopularity},
|
||||
}
|
||||
|
||||
for _, kind := range kinds {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mbids := imp.topMBIDs(kind.entityType, kind.limit)
|
||||
if len(mbids) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
batches := chunkStrings(mbids, popularityBatchSize)
|
||||
filled := 0
|
||||
|
||||
for i, batch := range batches {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
pops, err := kind.fetch(ctx, batch)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
filled += imp.si.updateListenerCounts(pops)
|
||||
|
||||
imp.setStageProgress(dumpStageListeners, i+1, len(batches))
|
||||
}
|
||||
|
||||
imp.logger.Info("dump import: listener counts patched",
|
||||
"entityType", kind.entityType,
|
||||
"rows", filled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// topMBIDs returns the most popular index MBIDs for an entity type
|
||||
// that don't have listener counts yet.
|
||||
func (imp *dumpImporter) topMBIDs(entityType string, limit int) []string {
|
||||
rows, err := imp.si.db.QueryContext(`
|
||||
SELECT mbid FROM explore_index
|
||||
WHERE entity_type = ? AND listener_count = 0
|
||||
ORDER BY popularity DESC
|
||||
LIMIT ?
|
||||
`, entityType, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var mbids []string
|
||||
|
||||
for rows.Next() {
|
||||
var m string
|
||||
if err := rows.Scan(&m); err == nil {
|
||||
mbids = append(mbids, m)
|
||||
}
|
||||
}
|
||||
|
||||
return mbids
|
||||
}
|
||||
|
||||
// updateListenerCounts writes listener counts only (never popularity),
|
||||
// keeping the dump-derived popularity scale consistent. Returns the
|
||||
// number of rows updated.
|
||||
func (si *SearchIndex) updateListenerCounts(updates map[string]PopularityData) int {
|
||||
if len(updates) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
tx, err := si.db.BeginTx()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
updated := 0
|
||||
|
||||
for mbid, data := range updates {
|
||||
if data.ListenerCount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
res, err := tx.Exec(
|
||||
`UPDATE explore_index
|
||||
SET listener_count = ?
|
||||
WHERE mbid = ? AND listener_count < ?`,
|
||||
data.ListenerCount, strings.ToLower(mbid), data.ListenerCount,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if n, err := res.RowsAffected(); err == nil {
|
||||
updated += int(n)
|
||||
}
|
||||
}
|
||||
|
||||
_ = tx.Commit()
|
||||
|
||||
return updated
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Streaming helpers for the MetaBrainz dump imports. Dumps are never
|
||||
// written to disk: the HTTP body is decoded (tar / zstd+tar) in flight.
|
||||
// resumableReader reconnects with HTTP Range requests on transient
|
||||
// failures, which also lets the listens import resume across app
|
||||
// restarts from a checkpointed byte offset.
|
||||
|
||||
const (
|
||||
// maxStreamRetries is the number of consecutive failed reconnect
|
||||
// attempts before a stream read gives up. The counter resets
|
||||
// whenever bytes are successfully delivered.
|
||||
maxStreamRetries = 8
|
||||
|
||||
// streamRetryBaseDelay is the initial reconnect backoff; it
|
||||
// doubles per consecutive failure.
|
||||
streamRetryBaseDelay = 2 * time.Second
|
||||
|
||||
// dumpDiscoveryTimeout bounds the small directory-listing
|
||||
// requests (not the multi-hour stream requests).
|
||||
dumpDiscoveryTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// ErrDumpDiscovery is returned when a dump directory listing does not
|
||||
// contain the expected entries.
|
||||
var ErrDumpDiscovery = errors.New("dump discovery failed")
|
||||
|
||||
// ErrDumpStream is returned when a dump stream fails permanently.
|
||||
var ErrDumpStream = errors.New("dump stream failed")
|
||||
|
||||
var hrefRe = regexp.MustCompile(`href="([^"?/][^"?]*)"`)
|
||||
|
||||
// listHrefs fetches an Apache-style index page and returns the href
|
||||
// values (directory entries end with a trailing slash).
|
||||
func listHrefs(ctx context.Context, client *http.Client, url string) ([]string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, dumpDiscoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dump listing request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", lbUserAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dump listing fetch: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: listing %s returned HTTP %d", ErrDumpDiscovery, url, resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dump listing read: %w", err)
|
||||
}
|
||||
|
||||
var hrefs []string
|
||||
|
||||
for _, m := range hrefRe.FindAllStringSubmatch(string(body), -1) {
|
||||
hrefs = append(hrefs, m[1])
|
||||
}
|
||||
|
||||
return hrefs, nil
|
||||
}
|
||||
|
||||
// discoverDumpFile walks a dump base directory, finds subdirectories
|
||||
// matching dirRe (newest first, lexicographically — MetaBrainz dump
|
||||
// directory names embed sortable timestamps), and returns the full URL
|
||||
// of the first file inside matching fileRe. Directories that don't
|
||||
// contain a matching file (e.g. partial uploads) are skipped.
|
||||
func discoverDumpFile(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
baseURL string,
|
||||
dirRe, fileRe *regexp.Regexp,
|
||||
) (string, error) {
|
||||
hrefs, err := listHrefs(ctx, client, baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var dirs []string
|
||||
|
||||
for _, h := range hrefs {
|
||||
trimmed := trimTrailingSlash(h)
|
||||
if dirRe.MatchString(trimmed) {
|
||||
dirs = append(dirs, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dirs) == 0 {
|
||||
return "", fmt.Errorf("%w: no dump directories under %s", ErrDumpDiscovery, baseURL)
|
||||
}
|
||||
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
|
||||
|
||||
for _, dir := range dirs {
|
||||
dirURL := baseURL + dir + "/"
|
||||
|
||||
files, err := listHrefs(ctx, client, dirURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if fileRe.MatchString(f) {
|
||||
return dirURL + f, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: no matching dump file under %s", ErrDumpDiscovery, baseURL)
|
||||
}
|
||||
|
||||
func trimTrailingSlash(s string) string {
|
||||
if len(s) > 0 && s[len(s)-1] == '/' {
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// resumableReader is an io.Reader over an HTTP resource that survives
|
||||
// connection failures by reconnecting with a Range request at the
|
||||
// current offset. Offset is the absolute position of the next byte to
|
||||
// deliver, so callers can checkpoint it and construct a new
|
||||
// resumableReader later to resume a partially-processed stream.
|
||||
type resumableReader struct {
|
||||
ctx context.Context
|
||||
client *http.Client
|
||||
url string
|
||||
|
||||
// Offset is the absolute byte position of the next read.
|
||||
Offset int64
|
||||
|
||||
// Size is the total resource size, learned from the first
|
||||
// response. -1 until known.
|
||||
Size int64
|
||||
|
||||
body io.ReadCloser
|
||||
retries int
|
||||
}
|
||||
|
||||
func newResumableReader(
|
||||
ctx context.Context, client *http.Client, url string, offset int64,
|
||||
) *resumableReader {
|
||||
return &resumableReader{
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
url: url,
|
||||
Offset: offset,
|
||||
Size: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resumableReader) Read(p []byte) (int, error) {
|
||||
for {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if r.body == nil {
|
||||
if err := r.connect(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
n, err := r.body.Read(p)
|
||||
r.Offset += int64(n)
|
||||
|
||||
if n > 0 {
|
||||
r.retries = 0
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
return n, nil
|
||||
case errors.Is(err, io.EOF):
|
||||
// A server that closes early looks like EOF; only
|
||||
// trust it when we've seen the advertised size.
|
||||
if r.Size >= 0 && r.Offset < r.Size {
|
||||
r.closeBody()
|
||||
|
||||
if retryErr := r.backoff(err); retryErr != nil {
|
||||
return n, retryErr
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return n, io.EOF
|
||||
default:
|
||||
r.closeBody()
|
||||
|
||||
if retryErr := r.backoff(err); retryErr != nil {
|
||||
return n, retryErr
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// backoff sleeps with exponential backoff, or returns a terminal error
|
||||
// once the retry budget is exhausted.
|
||||
func (r *resumableReader) backoff(cause error) error {
|
||||
r.retries++
|
||||
if r.retries > maxStreamRetries {
|
||||
return fmt.Errorf(
|
||||
"%w: %s after %d retries: %w", ErrDumpStream, r.url, maxStreamRetries, cause,
|
||||
)
|
||||
}
|
||||
|
||||
delay := streamRetryBaseDelay << (r.retries - 1)
|
||||
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
case <-time.After(delay):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resumableReader) connect() error {
|
||||
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, r.url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dump stream request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", lbUserAgent)
|
||||
|
||||
if r.Offset > 0 {
|
||||
req.Header.Set("Range", "bytes="+strconv.FormatInt(r.Offset, 10)+"-")
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return r.backoff(err)
|
||||
}
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
if r.Size < 0 {
|
||||
r.Size = parseContentRangeTotal(resp.Header.Get("Content-Range"))
|
||||
}
|
||||
|
||||
r.body = resp.Body
|
||||
|
||||
return nil
|
||||
case http.StatusOK:
|
||||
if r.Size < 0 && resp.ContentLength > 0 {
|
||||
r.Size = resp.ContentLength
|
||||
}
|
||||
|
||||
// Server ignored the Range header: discard the prefix so
|
||||
// the caller still reads from the requested offset.
|
||||
if r.Offset > 0 {
|
||||
if _, err := io.CopyN(io.Discard, resp.Body, r.Offset); err != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
return r.backoff(err)
|
||||
}
|
||||
}
|
||||
|
||||
r.body = resp.Body
|
||||
|
||||
return nil
|
||||
default:
|
||||
_ = resp.Body.Close()
|
||||
|
||||
return r.backoff(fmt.Errorf("%w: HTTP %d from %s", ErrDumpStream, resp.StatusCode, r.url))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resumableReader) closeBody() {
|
||||
if r.body != nil {
|
||||
_ = r.body.Close()
|
||||
r.body = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases the underlying HTTP body, if any.
|
||||
func (r *resumableReader) Close() error {
|
||||
r.closeBody()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseContentRangeTotal extracts the total size from a Content-Range
|
||||
// header ("bytes 100-199/12345"). Returns -1 if unavailable.
|
||||
func parseContentRangeTotal(v string) int64 {
|
||||
for i := len(v) - 1; i >= 0; i-- {
|
||||
if v[i] == '/' {
|
||||
total, err := strconv.ParseInt(v[i+1:], 10, 64)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package eval is the search-ranking evaluation harness. It turns
|
||||
// "this query feels wrong" into a number that goes up or down, so a
|
||||
// ranking change can be validated against a frozen set of labelled
|
||||
// queries instead of tuned by anecdote.
|
||||
//
|
||||
// The harness is deliberately decoupled from the explore package: it
|
||||
// knows nothing about MusicBrainz, ListenBrainz, or the search index.
|
||||
// A caller adapts whatever ranking function it wants to measure to the
|
||||
// Ranker interface, loads a fixture set, and runs Evaluate. The
|
||||
// explore package wires its real index Search to this in an
|
||||
// integration test (see explore/eval_harness_test.go).
|
||||
package eval
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrNoFixtures is returned when a fixture file contains zero queries.
|
||||
var ErrNoFixtures = errors.New("eval: fixture set is empty")
|
||||
|
||||
// Result is one ranked search hit, reduced to the only two fields the
|
||||
// harness needs to decide whether it matches an expectation.
|
||||
type Result struct {
|
||||
EntityType string `json:"entityType"`
|
||||
MBID string `json:"mbid"`
|
||||
}
|
||||
|
||||
// Ranker produces an ordered result list for a query. Best result
|
||||
// first. Implemented by adapting a real search function.
|
||||
type Ranker interface {
|
||||
Rank(query string, limit int) []Result
|
||||
}
|
||||
|
||||
// RankerFunc adapts a plain function to the Ranker interface.
|
||||
type RankerFunc func(query string, limit int) []Result
|
||||
|
||||
// Rank calls the underlying function.
|
||||
func (f RankerFunc) Rank(query string, limit int) []Result {
|
||||
return f(query, limit)
|
||||
}
|
||||
|
||||
// Expected is one acceptable result for a fixture query. Grade is the
|
||||
// graded-relevance weight used by nDCG (higher = more relevant); it
|
||||
// defaults to 1 when omitted. Type is optional — when set, a ranked
|
||||
// result must match both MBID and entity type to count as a hit.
|
||||
type Expected struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
MBID string `json:"mbid"`
|
||||
Grade int `json:"grade,omitempty"`
|
||||
}
|
||||
|
||||
// Fixture is a single labelled query: the input plus the result(s) a
|
||||
// user should get. Every edge case ever hand-fixed in the ranker
|
||||
// belongs here so it can never silently regress.
|
||||
type Fixture struct {
|
||||
Query string `json:"query"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Expect []Expected `json:"expect"`
|
||||
}
|
||||
|
||||
// LoadFixtures reads a JSON fixture file from disk.
|
||||
func LoadFixtures(path string) ([]Fixture, error) {
|
||||
f, err := os.Open(path) //nolint:gosec // path is a test fixture, not user input
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eval: open fixtures: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
return ParseFixtures(f)
|
||||
}
|
||||
|
||||
// ParseFixtures decodes a JSON fixture set from a reader.
|
||||
func ParseFixtures(r io.Reader) ([]Fixture, error) {
|
||||
var fixtures []Fixture
|
||||
|
||||
if err := json.NewDecoder(r).Decode(&fixtures); err != nil {
|
||||
return nil, fmt.Errorf("eval: decode fixtures: %w", err)
|
||||
}
|
||||
|
||||
if len(fixtures) == 0 {
|
||||
return nil, ErrNoFixtures
|
||||
}
|
||||
|
||||
return fixtures, nil
|
||||
}
|
||||
|
||||
// matches reports whether a ranked result satisfies an expectation.
|
||||
// MBID match is required; entity type is checked only when the
|
||||
// expectation pins one.
|
||||
func (e Expected) matches(r Result) bool {
|
||||
if !strings.EqualFold(e.MBID, r.MBID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if e.Type != "" && !strings.EqualFold(e.Type, r.EntityType) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// grade returns the graded-relevance weight, defaulting to 1.
|
||||
func (e Expected) grade() int {
|
||||
if e.Grade <= 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return e.Grade
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QueryScore holds the per-query metrics for one fixture.
|
||||
type QueryScore struct {
|
||||
Query string
|
||||
Note string
|
||||
|
||||
// BestRank is the 1-based rank of the highest-placed expected
|
||||
// result, or 0 if none of the expected results appear in topK.
|
||||
BestRank int
|
||||
|
||||
ReciprocalRank float64
|
||||
PrecisionAtK float64
|
||||
NDCGAtK float64
|
||||
}
|
||||
|
||||
// Hit reports whether any expected result landed in topK.
|
||||
func (q QueryScore) Hit() bool {
|
||||
return q.BestRank > 0
|
||||
}
|
||||
|
||||
// Report aggregates per-query scores into the numbers you watch across
|
||||
// a ranking change: mean reciprocal rank, mean precision@k, mean
|
||||
// nDCG@k, plus the list of queries that missed entirely.
|
||||
type Report struct {
|
||||
K int
|
||||
NumQueries int
|
||||
|
||||
MRR float64
|
||||
MeanPAtK float64
|
||||
MeanNDCG float64
|
||||
HitRate float64 // fraction of queries with any expected result in topK
|
||||
Top1Rate float64 // fraction whose best expected result is rank 1
|
||||
PerQuery []QueryScore
|
||||
}
|
||||
|
||||
// Evaluate runs every fixture through the ranker and aggregates the
|
||||
// results into a Report. topK bounds how deep a result can be and
|
||||
// still count (a result at rank 20 helps no one).
|
||||
func Evaluate(r Ranker, fixtures []Fixture, topK int) Report {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
report := Report{K: topK, NumQueries: len(fixtures)}
|
||||
|
||||
for _, fx := range fixtures {
|
||||
ranked := r.Rank(fx.Query, topK)
|
||||
report.PerQuery = append(report.PerQuery, scoreQuery(fx, ranked, topK))
|
||||
}
|
||||
|
||||
for _, q := range report.PerQuery {
|
||||
report.MRR += q.ReciprocalRank
|
||||
report.MeanPAtK += q.PrecisionAtK
|
||||
report.MeanNDCG += q.NDCGAtK
|
||||
|
||||
if q.Hit() {
|
||||
report.HitRate++
|
||||
}
|
||||
|
||||
if q.BestRank == 1 {
|
||||
report.Top1Rate++
|
||||
}
|
||||
}
|
||||
|
||||
if n := float64(len(fixtures)); n > 0 {
|
||||
report.MRR /= n
|
||||
report.MeanPAtK /= n
|
||||
report.MeanNDCG /= n
|
||||
report.HitRate /= n
|
||||
report.Top1Rate /= n
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
// scoreQuery computes the metrics for a single fixture against a ranked
|
||||
// result list.
|
||||
func scoreQuery(fx Fixture, ranked []Result, topK int) QueryScore {
|
||||
score := QueryScore{Query: fx.Query, Note: fx.Note}
|
||||
|
||||
limit := min(topK, len(ranked))
|
||||
|
||||
relevantInK := 0
|
||||
|
||||
for i := range limit {
|
||||
if !anyMatch(fx.Expect, ranked[i]) {
|
||||
continue
|
||||
}
|
||||
|
||||
relevantInK++
|
||||
|
||||
if score.BestRank == 0 {
|
||||
score.BestRank = i + 1
|
||||
score.ReciprocalRank = 1.0 / float64(i+1)
|
||||
}
|
||||
}
|
||||
|
||||
score.PrecisionAtK = float64(relevantInK) / float64(topK)
|
||||
score.NDCGAtK = ndcg(fx.Expect, ranked, topK)
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// anyMatch reports whether a result satisfies any expectation.
|
||||
func anyMatch(expected []Expected, r Result) bool {
|
||||
for _, e := range expected {
|
||||
if e.matches(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ndcg computes normalized discounted cumulative gain at k using graded
|
||||
// relevance. Returns 0 when there are no expected results.
|
||||
func ndcg(expected []Expected, ranked []Result, k int) float64 {
|
||||
ideal := idealDCG(expected, k)
|
||||
if ideal == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
limit := min(k, len(ranked))
|
||||
|
||||
dcg := 0.0
|
||||
|
||||
for i := range limit {
|
||||
g := matchedGrade(expected, ranked[i])
|
||||
if g == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dcg += gain(g, i)
|
||||
}
|
||||
|
||||
return dcg / ideal
|
||||
}
|
||||
|
||||
// matchedGrade returns the relevance grade for a result, or 0 if it
|
||||
// matches no expectation.
|
||||
func matchedGrade(expected []Expected, r Result) int {
|
||||
for _, e := range expected {
|
||||
if e.matches(r) {
|
||||
return e.grade()
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// idealDCG is the DCG of the best possible ordering: every expected
|
||||
// result, sorted by grade descending, placed at the front.
|
||||
func idealDCG(expected []Expected, k int) float64 {
|
||||
grades := make([]int, 0, len(expected))
|
||||
for _, e := range expected {
|
||||
grades = append(grades, e.grade())
|
||||
}
|
||||
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(grades)))
|
||||
|
||||
limit := min(k, len(grades))
|
||||
|
||||
ideal := 0.0
|
||||
for i := range limit {
|
||||
ideal += gain(grades[i], i)
|
||||
}
|
||||
|
||||
return ideal
|
||||
}
|
||||
|
||||
// gain is the discounted gain of a grade at 0-based position i.
|
||||
func gain(grade, i int) float64 {
|
||||
return (math.Pow(2, float64(grade)) - 1) / math.Log2(float64(i+2))
|
||||
}
|
||||
|
||||
// Format renders a Report as a human-readable table for test output.
|
||||
func (r Report) Format() string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "ranking eval — %d queries @k=%d\n", r.NumQueries, r.K)
|
||||
fmt.Fprintf(&b, " MRR %.3f\n", r.MRR)
|
||||
fmt.Fprintf(&b, " P@%d %.3f\n", r.K, r.MeanPAtK)
|
||||
fmt.Fprintf(&b, " nDCG@%d %.3f\n", r.K, r.MeanNDCG)
|
||||
fmt.Fprintf(&b, " hit rate %.3f\n", r.HitRate)
|
||||
fmt.Fprintf(&b, " top-1 rate %.3f\n", r.Top1Rate)
|
||||
|
||||
misses := r.Misses()
|
||||
if len(misses) > 0 {
|
||||
b.WriteString(" misses:\n")
|
||||
|
||||
for _, m := range misses {
|
||||
fmt.Fprintf(&b, " %-40q rank=%s\n", m.Query, rankLabel(m.BestRank))
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Misses returns the queries whose best expected result was absent
|
||||
// from topK or buried below rank 1 — the regression watch-list.
|
||||
func (r Report) Misses() []QueryScore {
|
||||
var out []QueryScore
|
||||
|
||||
for _, q := range r.PerQuery {
|
||||
if q.BestRank != 1 {
|
||||
out = append(out, q)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func rankLabel(rank int) string {
|
||||
if rank == 0 {
|
||||
return "absent"
|
||||
}
|
||||
|
||||
return strconv.Itoa(rank)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rankerFromIDs builds a Ranker that returns a fixed ordering keyed by
|
||||
// query, for deterministic metric tests.
|
||||
func rankerFromIDs(table map[string][]Result) Ranker {
|
||||
return RankerFunc(func(query string, limit int) []Result {
|
||||
out := table[query]
|
||||
if limit < len(out) {
|
||||
out = out[:limit]
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
func approx(a, b float64) bool {
|
||||
return math.Abs(a-b) < 1e-9
|
||||
}
|
||||
|
||||
func TestReciprocalRank(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ranked []Result
|
||||
expect []Expected
|
||||
wantRR float64
|
||||
wantPos int
|
||||
}{
|
||||
{
|
||||
name: "top result",
|
||||
ranked: []Result{{MBID: "a"}, {MBID: "b"}},
|
||||
expect: []Expected{{MBID: "a"}},
|
||||
wantRR: 1.0,
|
||||
wantPos: 1,
|
||||
},
|
||||
{
|
||||
name: "third result",
|
||||
ranked: []Result{{MBID: "x"}, {MBID: "y"}, {MBID: "a"}},
|
||||
expect: []Expected{{MBID: "a"}},
|
||||
wantRR: 1.0 / 3.0,
|
||||
wantPos: 3,
|
||||
},
|
||||
{
|
||||
name: "absent",
|
||||
ranked: []Result{{MBID: "x"}, {MBID: "y"}},
|
||||
expect: []Expected{{MBID: "a"}},
|
||||
wantRR: 0,
|
||||
wantPos: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fx := Fixture{Query: "q", Expect: tt.expect}
|
||||
got := scoreQuery(fx, tt.ranked, 5)
|
||||
|
||||
if !approx(got.ReciprocalRank, tt.wantRR) {
|
||||
t.Errorf("RR = %v, want %v", got.ReciprocalRank, tt.wantRR)
|
||||
}
|
||||
|
||||
if got.BestRank != tt.wantPos {
|
||||
t.Errorf("BestRank = %d, want %d", got.BestRank, tt.wantPos)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecisionAtK(t *testing.T) {
|
||||
fx := Fixture{
|
||||
Query: "q",
|
||||
Expect: []Expected{{MBID: "a"}, {MBID: "b"}},
|
||||
}
|
||||
ranked := []Result{{MBID: "a"}, {MBID: "x"}, {MBID: "b"}, {MBID: "y"}}
|
||||
|
||||
got := scoreQuery(fx, ranked, 4)
|
||||
|
||||
// 2 relevant out of k=4.
|
||||
if !approx(got.PrecisionAtK, 0.5) {
|
||||
t.Errorf("P@4 = %v, want 0.5", got.PrecisionAtK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDCGRespectsOrdering(t *testing.T) {
|
||||
expect := []Expected{{MBID: "a", Grade: 3}, {MBID: "b", Grade: 1}}
|
||||
|
||||
// Ideal ordering: high-grade result first.
|
||||
good := scoreQuery(
|
||||
Fixture{Query: "q", Expect: expect},
|
||||
[]Result{{MBID: "a"}, {MBID: "b"}, {MBID: "z"}},
|
||||
5,
|
||||
)
|
||||
|
||||
// Worse ordering: high-grade result buried below an irrelevant one.
|
||||
bad := scoreQuery(
|
||||
Fixture{Query: "q", Expect: expect},
|
||||
[]Result{{MBID: "z"}, {MBID: "b"}, {MBID: "a"}},
|
||||
5,
|
||||
)
|
||||
|
||||
if !approx(good.NDCGAtK, 1.0) {
|
||||
t.Errorf("ideal ordering nDCG = %v, want 1.0", good.NDCGAtK)
|
||||
}
|
||||
|
||||
if bad.NDCGAtK >= good.NDCGAtK {
|
||||
t.Errorf("worse ordering nDCG %v should be < ideal %v", bad.NDCGAtK, good.NDCGAtK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeMustMatchWhenPinned(t *testing.T) {
|
||||
fx := Fixture{
|
||||
Query: "q",
|
||||
Expect: []Expected{{Type: "artist", MBID: "a"}},
|
||||
}
|
||||
|
||||
// Same MBID but wrong entity type — must not count.
|
||||
wrongType := scoreQuery(fx, []Result{{EntityType: "recording", MBID: "a"}}, 5)
|
||||
if wrongType.Hit() {
|
||||
t.Error("result with wrong entity type counted as a hit")
|
||||
}
|
||||
|
||||
rightType := scoreQuery(fx, []Result{{EntityType: "artist", MBID: "a"}}, 5)
|
||||
if !rightType.Hit() {
|
||||
t.Error("result with matching entity type did not count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAggregates(t *testing.T) {
|
||||
fixtures := []Fixture{
|
||||
{Query: "hit-top", Expect: []Expected{{MBID: "a"}}},
|
||||
{Query: "hit-second", Expect: []Expected{{MBID: "a"}}},
|
||||
{Query: "miss", Expect: []Expected{{MBID: "a"}}},
|
||||
}
|
||||
|
||||
r := rankerFromIDs(map[string][]Result{
|
||||
"hit-top": {{MBID: "a"}},
|
||||
"hit-second": {{MBID: "x"}, {MBID: "a"}},
|
||||
"miss": {{MBID: "x"}, {MBID: "y"}},
|
||||
})
|
||||
|
||||
report := Evaluate(r, fixtures, 5)
|
||||
|
||||
// MRR = (1 + 1/2 + 0) / 3.
|
||||
wantMRR := (1.0 + 0.5 + 0.0) / 3.0
|
||||
if !approx(report.MRR, wantMRR) {
|
||||
t.Errorf("MRR = %v, want %v", report.MRR, wantMRR)
|
||||
}
|
||||
|
||||
// 2 of 3 queries surfaced the result somewhere in topK.
|
||||
if !approx(report.HitRate, 2.0/3.0) {
|
||||
t.Errorf("HitRate = %v, want %v", report.HitRate, 2.0/3.0)
|
||||
}
|
||||
|
||||
// Only 1 of 3 had it at rank 1.
|
||||
if !approx(report.Top1Rate, 1.0/3.0) {
|
||||
t.Errorf("Top1Rate = %v, want %v", report.Top1Rate, 1.0/3.0)
|
||||
}
|
||||
|
||||
if len(report.Misses()) != 2 {
|
||||
t.Errorf("Misses = %d, want 2", len(report.Misses()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFixtures(t *testing.T) {
|
||||
const doc = `[
|
||||
{"query": "radiohead", "expect": [{"type": "artist", "mbid": "abc"}]},
|
||||
{"query": "ok computer", "note": "album not band", "expect": [{"mbid": "def", "grade": 2}]}
|
||||
]`
|
||||
|
||||
fixtures, err := ParseFixtures(strings.NewReader(doc))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFixtures: %v", err)
|
||||
}
|
||||
|
||||
if len(fixtures) != 2 {
|
||||
t.Fatalf("got %d fixtures, want 2", len(fixtures))
|
||||
}
|
||||
|
||||
if fixtures[0].Expect[0].MBID != "abc" {
|
||||
t.Errorf("MBID = %q, want abc", fixtures[0].Expect[0].MBID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFixturesEmpty(t *testing.T) {
|
||||
_, err := ParseFixtures(strings.NewReader(`[]`))
|
||||
if err == nil {
|
||||
t.Fatal("expected ErrNoFixtures, got nil")
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"query": "radiohead",
|
||||
"note": "single-word artist name — should resolve to the artist, not an album titled similarly",
|
||||
"expect": [{ "type": "artist", "mbid": "a74b1b7f-71a5-4011-9441-d0b5e4122711" }]
|
||||
},
|
||||
{
|
||||
"query": "the beatles",
|
||||
"note": "common-word prefix must not let the article dominate",
|
||||
"expect": [{ "type": "artist", "mbid": "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d" }]
|
||||
},
|
||||
{
|
||||
"query": "abbey road",
|
||||
"note": "album title — should rank the release group above any track of the same name",
|
||||
"expect": [{ "type": "release_group", "mbid": "" }]
|
||||
},
|
||||
{
|
||||
"query": "calling you blue october",
|
||||
"note": "composite title+artist query — recording should win even if the artist is unindexed",
|
||||
"expect": [{ "type": "recording", "mbid": "" }]
|
||||
},
|
||||
{
|
||||
"query": "the teenagers",
|
||||
"note": "regression: must rank The Teenagers above The Beatles despite far lower popularity",
|
||||
"expect": [{ "type": "artist", "mbid": "" }]
|
||||
},
|
||||
{
|
||||
"query": "beyonce",
|
||||
"note": "diacritic folding (migration 37): unaccented query must find the accented artist Beyoncé",
|
||||
"expect": [{ "type": "artist", "mbid": "859d0860-d480-4efd-970c-c05d5f1776b8" }]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/explore/eval"
|
||||
)
|
||||
|
||||
// seedIndexRow inserts one explore_index row. The FTS triggers keep
|
||||
// explore_index_fts in sync automatically.
|
||||
func seedIndexRow(
|
||||
t *testing.T,
|
||||
db *database.DB,
|
||||
entityType, mbid, title, artist string,
|
||||
popularity int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
_, err := db.ExecContext(`
|
||||
INSERT INTO explore_index
|
||||
(entity_type, mbid, title, artist_name, artist_mbid, popularity, listener_count)
|
||||
VALUES (?, ?, ?, ?, '', ?, ?)
|
||||
`, entityType, mbid, title, artist, popularity, popularity/10)
|
||||
if err != nil {
|
||||
t.Fatalf("seed %s/%s: %v", entityType, mbid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIndex builds a SearchIndex over a seeded in-memory DB. lb and
|
||||
// artistImg are nil because Search touches neither.
|
||||
func newTestIndex(t *testing.T, db *database.DB) *SearchIndex {
|
||||
t.Helper()
|
||||
|
||||
idx := NewSearchIndex(db, nil, nil, slog.Default())
|
||||
idx.MarkReadyIfPopulated()
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// TestEvalHarnessIndexRanking is the end-to-end wiring of the eval
|
||||
// harness against the real FTS index Search. It seeds a controlled
|
||||
// corpus where the correct answer is known, then asserts the harness
|
||||
// reports a perfect score — proving both the index ranking and the
|
||||
// harness plumbing on a case we fully control.
|
||||
func TestEvalHarnessIndexRanking(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
// Popular exact-match artist should beat a more obscure namesake
|
||||
// and an unrelated album.
|
||||
seedIndexRow(t, db, "artist", "rh", "Radiohead", "Radiohead", 5_000_000)
|
||||
seedIndexRow(t, db, "artist", "radio-obscure", "Radio Birdman", "Radio Birdman", 40_000)
|
||||
seedIndexRow(t, db, "release_group", "okc", "OK Computer", "Radiohead", 2_000_000)
|
||||
seedIndexRow(t, db, "artist", "beatles", "The Beatles", "The Beatles", 9_000_000)
|
||||
seedIndexRow(t, db, "artist", "teenagers", "The Teenagers", "The Teenagers", 60_000)
|
||||
|
||||
idx := newTestIndex(t, db)
|
||||
|
||||
ranker := eval.RankerFunc(func(query string, limit int) []eval.Result {
|
||||
hits := idx.Search(context.Background(), query, limit)
|
||||
out := make([]eval.Result, 0, len(hits))
|
||||
|
||||
for _, h := range hits {
|
||||
out = append(out, eval.Result{EntityType: h.EntityType, MBID: h.MBID})
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
fixtures := []eval.Fixture{
|
||||
{
|
||||
Query: "radiohead",
|
||||
Note: "popular exact artist match",
|
||||
Expect: []eval.Expected{{Type: "artist", MBID: "rh"}},
|
||||
},
|
||||
{
|
||||
Query: "the teenagers",
|
||||
Note: "low-popularity exact match must beat high-popularity article match",
|
||||
Expect: []eval.Expected{{Type: "artist", MBID: "teenagers"}},
|
||||
},
|
||||
}
|
||||
|
||||
report := eval.Evaluate(ranker, fixtures, 5)
|
||||
t.Log("\n" + report.Format())
|
||||
|
||||
if report.HitRate < 1.0 {
|
||||
t.Errorf("expected every query to surface its result, got hit rate %.3f", report.HitRate)
|
||||
}
|
||||
|
||||
if report.Top1Rate < 1.0 {
|
||||
t.Errorf("expected every result at rank 1, got top-1 rate %.3f:\n%s",
|
||||
report.Top1Rate, report.Format())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExploreFTSDiacriticFolding proves migration 37: an unaccented
|
||||
// query must find an accented title (and vice versa) now that
|
||||
// explore_index_fts folds diacritics.
|
||||
func TestExploreFTSDiacriticFolding(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
seedIndexRow(t, db, "artist", "bey", "Beyoncé", "Beyoncé", 8_000_000)
|
||||
seedIndexRow(t, db, "artist", "bjork", "Björk", "Björk", 3_000_000)
|
||||
|
||||
idx := newTestIndex(t, db)
|
||||
|
||||
cases := []struct {
|
||||
query string
|
||||
wantMBID string
|
||||
}{
|
||||
{"beyonce", "bey"}, // unaccented query → accented title
|
||||
{"beyoncé", "bey"}, // accented query still works
|
||||
{"bjork", "bjork"}, // ö → o folding
|
||||
{"björk", "bjork"}, // accented query still works
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.query, func(t *testing.T) {
|
||||
hits := idx.Search(context.Background(), tc.query, 5)
|
||||
if len(hits) == 0 {
|
||||
t.Fatalf("query %q returned no hits", tc.query)
|
||||
}
|
||||
|
||||
if hits[0].MBID != tc.wantMBID {
|
||||
t.Errorf("query %q: top hit = %q, want %q", tc.query, hits[0].MBID, tc.wantMBID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvalFixtureFileParses guards the checked-in fixture file so a
|
||||
// malformed edit fails fast rather than silently skipping queries.
|
||||
func TestEvalFixtureFileParses(t *testing.T) {
|
||||
fixtures, err := eval.LoadFixtures("eval/testdata/eval_queries.json")
|
||||
if err != nil {
|
||||
t.Fatalf("load fixtures: %v", err)
|
||||
}
|
||||
|
||||
for i, fx := range fixtures {
|
||||
if fx.Query == "" {
|
||||
t.Errorf("fixture %d has empty query", i)
|
||||
}
|
||||
|
||||
if len(fx.Expect) == 0 {
|
||||
t.Errorf("fixture %q has no expectations", fx.Query)
|
||||
}
|
||||
}
|
||||
}
|
||||
+726
-1416
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
package explore
|
||||
|
||||
import "strings"
|
||||
|
||||
// Character-bigram similarity, used by the search index's typo-tolerant
|
||||
// rescue pass (see fuzzyRescue). A name is represented as the set of its
|
||||
// sliding 2-rune windows; a single-character typo alters only the one or
|
||||
// two bigrams that span it, so most of the set survives. Overlap between
|
||||
// two such sets (Dice coefficient) therefore stays high across a
|
||||
// misspelling, where prefix matching collapses to nothing.
|
||||
|
||||
// fuzzyNormalize lowercases and collapses runs of whitespace to a single
|
||||
// space so bigram sets are stable across casing and spacing noise.
|
||||
func fuzzyNormalize(s string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||||
}
|
||||
|
||||
// fuzzyBigrams returns the set of character bigrams of s after
|
||||
// normalization. Returns nil for inputs shorter than two runes, which
|
||||
// have no bigram and can't be scored.
|
||||
func fuzzyBigrams(s string) map[string]struct{} {
|
||||
runes := []rune(fuzzyNormalize(s))
|
||||
if len(runes) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
set := make(map[string]struct{}, len(runes))
|
||||
|
||||
for i := 0; i+1 < len(runes); i++ {
|
||||
set[string(runes[i:i+2])] = struct{}{}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
// diceCoefficient is 2·|A∩B| / (|A|+|B|), a similarity in [0, 1] where 1
|
||||
// is an identical bigram set and 0 is disjoint. Iterating the smaller
|
||||
// set keeps the intersection count cheap.
|
||||
func diceCoefficient(a, b map[string]struct{}) float64 {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
small, large := a, b
|
||||
if len(large) < len(small) {
|
||||
small, large = large, small
|
||||
}
|
||||
|
||||
intersection := 0
|
||||
|
||||
for bg := range small {
|
||||
if _, ok := large[bg]; ok {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
|
||||
return 2 * float64(intersection) / float64(len(a)+len(b))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package explore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDiceCoefficientTypoTolerance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
wantMin float64 // score must be at least this
|
||||
wantMax float64 // ...and at most this
|
||||
}{
|
||||
{name: "identical", a: "beatles", b: "beatles", wantMin: 1.0, wantMax: 1.0},
|
||||
{name: "single typo", a: "beetles", b: "beatles", wantMin: 0.5, wantMax: 0.9},
|
||||
{name: "missing char", a: "nirvna", b: "nirvana", wantMin: 0.5, wantMax: 0.95},
|
||||
// Longer-name typo: a single substitution stays comfortably above
|
||||
// the rescue threshold, which is the realistic case (artist and
|
||||
// album names are rarely as short as five characters).
|
||||
{name: "longer name typo", a: "metalica", b: "metallica", wantMin: 0.5, wantMax: 0.95},
|
||||
// Adjacent transposition in a short word is bigrams' known weak
|
||||
// spot — it breaks most windows, so it scores below threshold.
|
||||
// Documented, not a bug: bigram overlap targets substitution,
|
||||
// insertion, and deletion typos.
|
||||
{name: "short transposition", a: "raido", b: "radio", wantMin: 0.0, wantMax: 0.34},
|
||||
{name: "case and space", a: "The Beatles", b: "the beatles", wantMin: 1.0, wantMax: 1.0},
|
||||
{name: "unrelated", a: "beatles", b: "metallica", wantMin: 0.0, wantMax: 0.34},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := diceCoefficient(fuzzyBigrams(tc.a), fuzzyBigrams(tc.b))
|
||||
if got < tc.wantMin || got > tc.wantMax {
|
||||
t.Errorf("diceCoefficient(%q, %q) = %.3f, want in [%.2f, %.2f]",
|
||||
tc.a, tc.b, got, tc.wantMin, tc.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyBigramsShortInput(t *testing.T) {
|
||||
if got := fuzzyBigrams("a"); got != nil {
|
||||
t.Errorf("fuzzyBigrams(%q) = %v, want nil", "a", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// lrclibBaseURL is the LRCLIB lyrics API. LRCLIB is a free,
|
||||
// community-maintained lyrics database (plain + synced) with no
|
||||
// auth required.
|
||||
lrclibBaseURL = "https://lrclib.net"
|
||||
|
||||
// lrclibUserAgent identifies the app per LRCLIB's guidelines.
|
||||
lrclibUserAgent = "YellowJacket (https://github.com/yellowjacket)"
|
||||
|
||||
// lrclibRate is the requests-per-second budget for LRCLIB. The
|
||||
// service has no hard published limit but asks clients to be
|
||||
// gentle; this keeps the library backfill polite.
|
||||
lrclibRate = 3
|
||||
)
|
||||
|
||||
// ErrLyricsNotFound is returned when LRCLIB has no match for the
|
||||
// requested track.
|
||||
var ErrLyricsNotFound = errors.New("lyrics not found")
|
||||
|
||||
// Lyrics holds the plain and (optional) time-synced lyrics for a
|
||||
// track, plus whether the track is marked instrumental.
|
||||
type Lyrics struct {
|
||||
Plain string `json:"plain"`
|
||||
Synced string `json:"synced"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
}
|
||||
|
||||
// LRCLibClient is a thin, rate-limited, cached HTTP client for the
|
||||
// LRCLIB lyrics API.
|
||||
type LRCLibClient struct {
|
||||
http *http.Client
|
||||
limiter *RateLimiter
|
||||
cache *Cache
|
||||
logger *slog.Logger
|
||||
baseURL string // overridable in tests
|
||||
}
|
||||
|
||||
// NewLRCLibClient creates an LRCLIB client sharing the given cache.
|
||||
func NewLRCLibClient(cache *Cache, logger *slog.Logger) *LRCLibClient {
|
||||
return &LRCLibClient{
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
limiter: NewRateLimiterN(lrclibRate),
|
||||
cache: cache,
|
||||
logger: logger,
|
||||
baseURL: lrclibBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// lrclibResponse is the wire shape of LRCLIB's /api/get response.
|
||||
type lrclibResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration float64 `json:"duration"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
// GetLyrics fetches lyrics for a track by artist, title, album, and
|
||||
// duration (seconds; pass 0 if unknown). LRCLIB matches on the
|
||||
// metadata with a small duration tolerance. Returns ErrLyricsNotFound
|
||||
// when no match exists. Successful and negative results are both
|
||||
// cached so a repeated backfill doesn't re-hit the network.
|
||||
func (c *LRCLibClient) GetLyrics(
|
||||
ctx context.Context, artist, title, album string, durationSec int,
|
||||
) (*Lyrics, error) {
|
||||
if artist == "" || title == "" {
|
||||
return nil, ErrLyricsNotFound
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("artist_name", artist)
|
||||
q.Set("track_name", title)
|
||||
|
||||
if album != "" {
|
||||
q.Set("album_name", album)
|
||||
}
|
||||
|
||||
if durationSec > 0 {
|
||||
q.Set("duration", strconv.Itoa(durationSec))
|
||||
}
|
||||
|
||||
reqURL := c.baseURL + "/api/get?" + q.Encode()
|
||||
cacheKey := "lrclib:get:" + q.Encode()
|
||||
|
||||
if data, ok := c.cache.Get(cacheKey); ok {
|
||||
return decodeCachedLyrics(data)
|
||||
}
|
||||
|
||||
body, status, err := c.doGet(ctx, reqURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lrclib get: %w", err)
|
||||
}
|
||||
|
||||
if status == http.StatusNotFound {
|
||||
// Cache the miss as a sentinel so we don't re-request it.
|
||||
c.cache.Set(cacheKey, []byte(lyricsMissSentinel), cacheTTLSearch, "", "lyrics")
|
||||
|
||||
return nil, ErrLyricsNotFound
|
||||
}
|
||||
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, fmt.Errorf("lrclib get: %w: status %d", ErrListenBrainzHTTP, status)
|
||||
}
|
||||
|
||||
var wire lrclibResponse
|
||||
if err := json.Unmarshal(body, &wire); err != nil {
|
||||
return nil, fmt.Errorf("lrclib get unmarshal: %w", err)
|
||||
}
|
||||
|
||||
lyrics := &Lyrics{
|
||||
Plain: wire.PlainLyrics,
|
||||
Synced: wire.SyncedLyrics,
|
||||
Instrumental: wire.Instrumental,
|
||||
}
|
||||
|
||||
// Persist the normalized result (not the raw wire body) so the
|
||||
// cached shape matches what callers expect.
|
||||
if encoded, err := json.Marshal(lyrics); err == nil {
|
||||
c.cache.Set(cacheKey, encoded, cacheTTLSearch, "", "lyrics")
|
||||
}
|
||||
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// lyricsMissSentinel marks a cached "no lyrics found" result.
|
||||
const lyricsMissSentinel = "\x00miss"
|
||||
|
||||
// decodeCachedLyrics interprets a cached LRCLIB payload, mapping the
|
||||
// miss sentinel back to ErrLyricsNotFound.
|
||||
func decodeCachedLyrics(data []byte) (*Lyrics, error) {
|
||||
if string(data) == lyricsMissSentinel {
|
||||
return nil, ErrLyricsNotFound
|
||||
}
|
||||
|
||||
var lyrics Lyrics
|
||||
if err := json.Unmarshal(data, &lyrics); err != nil {
|
||||
return nil, fmt.Errorf("lrclib cache decode: %w", err)
|
||||
}
|
||||
|
||||
return &lyrics, nil
|
||||
}
|
||||
|
||||
// doGet performs a rate-limited GET and returns the body and status.
|
||||
// Unlike the ListenBrainz client, a 404 is a normal "no lyrics"
|
||||
// outcome, so the status is returned rather than folded into an error.
|
||||
func (c *LRCLibClient) doGet(ctx context.Context, reqURL string) ([]byte, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, fmt.Errorf("rate limiter: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", lrclibUserAgent)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// newTestLRCLib builds an LRCLIB client whose HTTP calls hit the given
|
||||
// test server, sharing a real (in-memory) cache so caching behaviour is
|
||||
// exercised.
|
||||
func newTestLRCLib(t *testing.T, handler http.HandlerFunc) (*LRCLibClient, *httptest.Server) {
|
||||
t.Helper()
|
||||
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
cache := NewCache(db, testLogger())
|
||||
|
||||
c := NewLRCLibClient(cache, testLogger())
|
||||
// Point the client at the test server instead of the real API by
|
||||
// overriding its transport to rewrite the host.
|
||||
c.http = srv.Client()
|
||||
c.baseURL = srv.URL
|
||||
|
||||
return c, srv
|
||||
}
|
||||
|
||||
func TestLRCLibGetLyrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls int
|
||||
|
||||
c, _ := newTestLRCLib(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
|
||||
if r.URL.Path != "/api/get" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("track_name") == "Missing" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": 42,
|
||||
"trackName": "Yesterday",
|
||||
"artistName": "The Beatles",
|
||||
"albumName": "Help!",
|
||||
"duration": 125,
|
||||
"instrumental": false,
|
||||
"plainLyrics": "Yesterday, all my troubles seemed so far away",
|
||||
"syncedLyrics": "[00:00.00] Yesterday"
|
||||
}`))
|
||||
})
|
||||
|
||||
t.Run("hit returns plain + synced lyrics", func(t *testing.T) {
|
||||
lyrics, err := c.GetLyrics(context.Background(), "The Beatles", "Yesterday", "Help!", 125)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLyrics: %v", err)
|
||||
}
|
||||
|
||||
if lyrics.Plain == "" || lyrics.Synced == "" {
|
||||
t.Errorf("expected plain and synced lyrics, got %+v", lyrics)
|
||||
}
|
||||
|
||||
if lyrics.Instrumental {
|
||||
t.Error("expected non-instrumental")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("second identical request is served from cache", func(t *testing.T) {
|
||||
before := calls
|
||||
|
||||
if _, err := c.GetLyrics(
|
||||
context.Background(),
|
||||
"The Beatles",
|
||||
"Yesterday",
|
||||
"Help!",
|
||||
125,
|
||||
); err != nil {
|
||||
t.Fatalf("GetLyrics: %v", err)
|
||||
}
|
||||
|
||||
if calls != before {
|
||||
t.Errorf("expected cache hit (no new HTTP call), calls went %d → %d", before, calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("404 maps to ErrLyricsNotFound", func(t *testing.T) {
|
||||
_, err := c.GetLyrics(context.Background(), "Nobody", "Missing", "", 0)
|
||||
if !errors.Is(err, ErrLyricsNotFound) {
|
||||
t.Errorf("expected ErrLyricsNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing artist/title short-circuits without a request", func(t *testing.T) {
|
||||
before := calls
|
||||
|
||||
if _, err := c.GetLyrics(
|
||||
context.Background(),
|
||||
"",
|
||||
"Yesterday",
|
||||
"",
|
||||
0,
|
||||
); !errors.Is(
|
||||
err,
|
||||
ErrLyricsNotFound,
|
||||
) {
|
||||
t.Errorf("expected ErrLyricsNotFound, got %v", err)
|
||||
}
|
||||
|
||||
if calls != before {
|
||||
t.Error("expected no HTTP call for empty artist")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// LyricsResult is a single lyric-search hit, mapped from the DB layer
|
||||
// into the camelCase shape the frontend consumes.
|
||||
type LyricsResult struct {
|
||||
RecordingID int64 `json:"recordingId"`
|
||||
FilePath string `json:"filePath"`
|
||||
LengthMs int64 `json:"lengthMs"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
}
|
||||
|
||||
// TrackLyrics is the stored or freshly-fetched lyrics for one track.
|
||||
// Source is "embedded" (from the file's tags / library DB), "lrclib"
|
||||
// (fetched on demand), or "" when none are available.
|
||||
type TrackLyrics struct {
|
||||
Plain string `json:"plain"`
|
||||
Synced string `json:"synced"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
const (
|
||||
// lyricsSearchLimit caps lyric-search hits returned to the UI.
|
||||
lyricsSearchLimit = 30
|
||||
|
||||
// lyricsBackfillBatch is how many missing-lyrics recordings the
|
||||
// background backfill processes per pass.
|
||||
lyricsBackfillBatch = 200
|
||||
|
||||
// lyricsBackfillMaxPasses bounds a single backfill run so it can't
|
||||
// loop forever on a huge library; the next launch resumes where
|
||||
// this one left off (candidates with lyrics now filled are skipped).
|
||||
lyricsBackfillMaxPasses = 25
|
||||
)
|
||||
|
||||
// SearchLyrics finds library tracks whose lyrics contain the given
|
||||
// fragment, ranked by relevance. Pure local FTS — no network.
|
||||
func (e *Service) SearchLyrics(query string) []LyricsResult {
|
||||
hits, err := e.db.SearchLyrics(query, lyricsSearchLimit)
|
||||
if err != nil {
|
||||
e.logger.Warn("lyrics search failed", "query", query, "err", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]LyricsResult, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
out = append(out, LyricsResult{
|
||||
RecordingID: h.RecordingID,
|
||||
FilePath: h.FilePath,
|
||||
LengthMs: h.LengthMilliseconds,
|
||||
Title: h.Title,
|
||||
Artist: h.Artist,
|
||||
Album: h.Album,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// GetTrackLyrics returns lyrics for a recording. If the library
|
||||
// already has them (from embedded tags) they're returned as-is;
|
||||
// otherwise it fetches from LRCLIB, persists them (updating the FTS
|
||||
// index), and returns them. Never returns an error to the frontend —
|
||||
// a miss just yields an empty result.
|
||||
func (e *Service) GetTrackLyrics(recordingID int64) TrackLyrics {
|
||||
stored, err := e.db.GetRecordingLyrics(recordingID)
|
||||
if err == nil && stored != "" {
|
||||
return TrackLyrics{Plain: stored, Source: "embedded"}
|
||||
}
|
||||
|
||||
lookup, err := e.db.RecordingLyricLookup(recordingID)
|
||||
if err != nil || lookup == nil {
|
||||
return TrackLyrics{}
|
||||
}
|
||||
|
||||
fetched := e.fetchAndStoreLyrics(e.ctx, *lookup)
|
||||
if fetched == nil {
|
||||
return TrackLyrics{}
|
||||
}
|
||||
|
||||
return TrackLyrics{
|
||||
Plain: fetched.Plain,
|
||||
Synced: fetched.Synced,
|
||||
Instrumental: fetched.Instrumental,
|
||||
Source: "lrclib",
|
||||
}
|
||||
}
|
||||
|
||||
// RebuildLyricsIndex rebuilds the FTS lyrics index from the current
|
||||
// library. Cheap; safe to call after every scan.
|
||||
func (e *Service) RebuildLyricsIndex() {
|
||||
if err := e.db.RebuildLyricsIndex(); err != nil {
|
||||
e.logger.Warn("lyrics index rebuild failed", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
e.index.setMeta(lyricsIndexReadyKey, "1")
|
||||
e.logger.Info("lyrics index rebuilt")
|
||||
}
|
||||
|
||||
// RebuildLyricsIndexIfNeeded rebuilds the lyrics FTS only when it has not
|
||||
// been built since the last library change. The backfill keeps the index
|
||||
// in sync incrementally thereafter, so on an unchanged library the full
|
||||
// rebuild is redundant; the scan-completion path calls the unconditional
|
||||
// form.
|
||||
func (e *Service) RebuildLyricsIndexIfNeeded() {
|
||||
if e.index.hasMeta(lyricsIndexReadyKey) {
|
||||
return
|
||||
}
|
||||
|
||||
e.RebuildLyricsIndex()
|
||||
}
|
||||
|
||||
// BackfillLibraryLyrics fetches lyrics from LRCLIB for library tracks
|
||||
// that don't have them, in the background. Idempotent and bounded —
|
||||
// each recording is tried once (a miss is cached), and a run stops
|
||||
// after a fixed number of passes, resuming on the next launch.
|
||||
func (e *Service) BackfillLibraryLyrics() {
|
||||
go e.backfillLibraryLyrics(e.ctx)
|
||||
}
|
||||
|
||||
func (e *Service) backfillLibraryLyrics(ctx context.Context) {
|
||||
total := 0
|
||||
|
||||
for range lyricsBackfillMaxPasses {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
candidates, err := e.db.RecordingsMissingLyrics(lyricsBackfillBatch)
|
||||
if err != nil {
|
||||
e.logger.Warn("lyrics backfill: query failed", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
filled := 0
|
||||
|
||||
for _, c := range candidates {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if e.fetchAndStoreLyrics(ctx, c) != nil {
|
||||
filled++
|
||||
total++
|
||||
}
|
||||
}
|
||||
|
||||
// If a whole batch produced no stored lyrics, every remaining
|
||||
// candidate is a cached miss with no new data — stop early
|
||||
// rather than spinning through identical misses.
|
||||
if filled == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if total > 0 {
|
||||
e.logger.Info("lyrics backfill complete", "filled", total)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAndStoreLyrics looks up a single candidate on LRCLIB and, on a
|
||||
// non-instrumental hit with plain lyrics, persists it to the recording
|
||||
// (which also updates the FTS index). Returns the fetched lyrics, or
|
||||
// nil on any miss/error. Instrumental hits are recorded as an empty
|
||||
// lyrics string so they still count as "resolved" and aren't retried.
|
||||
func (e *Service) fetchAndStoreLyrics(
|
||||
ctx context.Context, c database.LyricsCandidate,
|
||||
) *Lyrics {
|
||||
durationSec := int(c.LengthMilliseconds / 1000) //nolint:mnd
|
||||
|
||||
lyrics, err := e.lrclib.GetLyrics(ctx, c.Artist, c.Title, c.Album, durationSec)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrLyricsNotFound) {
|
||||
e.logger.Debug("lyrics fetch failed",
|
||||
"artist", c.Artist, "title", c.Title, "err", err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if lyrics.Instrumental || lyrics.Plain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.db.SetRecordingLyrics(c.RecordingID, lyrics.Plain); err != nil {
|
||||
e.logger.Warn("lyrics store failed", "recordingId", c.RecordingID, "err", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return lyrics
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package explore
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestMergeIndexHitsBlendedScale is the regression test for the
|
||||
// merge→filter scale mismatch. An exact-match, in-library album that
|
||||
// exists only in the local index (not returned by MB) with very low
|
||||
// popularity must survive filterAndCap and outrank a weakly-matching MB
|
||||
// result — because index hits are now scored on the same blended scale.
|
||||
//
|
||||
// Under the previous behaviour the index hit was scored as
|
||||
// scalePopularity(50)*0.5 ≈ 12, below minBlendedScore (15), so
|
||||
// filterAndCap dropped it entirely.
|
||||
func TestMergeIndexHitsBlendedScale(t *testing.T) {
|
||||
// A weakly-matching MB release group, already reranked: ~0.35
|
||||
// relevance, no popularity → blended Score ≈ 35.
|
||||
result := MBSearchResult{
|
||||
ReleaseGroups: []MBReleaseGroup{
|
||||
{MBID: "mb-weak", Title: "Live At Wembley", Score: 35, Popularity: 0},
|
||||
},
|
||||
}
|
||||
|
||||
// An exact-match, in-library album from the local index with only
|
||||
// 50 listens.
|
||||
hits := []SearchIndexResult{
|
||||
{
|
||||
EntityType: "release_group",
|
||||
MBID: "idx-exact",
|
||||
Title: "Abbey Road",
|
||||
ArtistName: "The Beatles",
|
||||
Popularity: 50,
|
||||
InLibrary: true,
|
||||
},
|
||||
}
|
||||
|
||||
mergeIndexHits("abbey road", &result, hits)
|
||||
|
||||
if got := result.ReleaseGroups[0].MBID; got != "idx-exact" {
|
||||
t.Fatalf("expected exact in-library index hit to rank first, got %q (score %d)",
|
||||
got, result.ReleaseGroups[0].Score)
|
||||
}
|
||||
|
||||
idxScore := result.ReleaseGroups[0].Score
|
||||
if idxScore < minBlendedScore {
|
||||
t.Errorf("index hit score %d below minBlendedScore %d — would be filtered out",
|
||||
idxScore, minBlendedScore)
|
||||
}
|
||||
|
||||
// It must survive the filter that previously dropped it.
|
||||
filterAndCap(&result)
|
||||
|
||||
if len(result.ReleaseGroups) == 0 || result.ReleaseGroups[0].MBID != "idx-exact" {
|
||||
t.Fatalf("index hit did not survive filterAndCap: %+v", result.ReleaseGroups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeIndexHitsSortsRecordings guards the recordings path, which
|
||||
// previously had no post-merge sort: a high-scoring index recording was
|
||||
// merged but its position depended on prepend order, not score.
|
||||
func TestMergeIndexHitsSortsRecordings(t *testing.T) {
|
||||
result := MBSearchResult{
|
||||
Recordings: []MBRecording{
|
||||
{MBID: "mb-a", Title: "Filler", Score: 60, Popularity: 100},
|
||||
{MBID: "mb-b", Title: "Filler Two", Score: 20, Popularity: 50},
|
||||
},
|
||||
}
|
||||
|
||||
hits := []SearchIndexResult{
|
||||
{
|
||||
EntityType: "recording",
|
||||
MBID: "idx-exact",
|
||||
Title: "Calling You",
|
||||
Popularity: 100_000,
|
||||
InLibrary: true,
|
||||
},
|
||||
}
|
||||
|
||||
mergeIndexHits("calling you", &result, hits)
|
||||
|
||||
// Exact + in-library + decent popularity should land on top, and
|
||||
// the whole list must be in descending Score order.
|
||||
if result.Recordings[0].MBID != "idx-exact" {
|
||||
t.Errorf("exact in-library recording should rank first, got %q", result.Recordings[0].MBID)
|
||||
}
|
||||
|
||||
for i := 1; i < len(result.Recordings); i++ {
|
||||
if result.Recordings[i-1].Score < result.Recordings[i].Score {
|
||||
t.Errorf("recordings not sorted by score descending: %+v", result.Recordings)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexHitRelevanceTiers(t *testing.T) {
|
||||
const q = "abbey road"
|
||||
|
||||
exact := indexHitRelevance(q, "Abbey Road", "")
|
||||
prefix := indexHitRelevance(q, "Abbey Road Sessions", "")
|
||||
word := indexHitRelevance(q, "Live: Abbey Road Medley", "")
|
||||
none := indexHitRelevance(q, "Something Unrelated", "")
|
||||
|
||||
if !(exact > prefix && prefix > word && word >= indexRelevanceFloor) {
|
||||
t.Errorf("relevance tiers not ordered: exact=%v prefix=%v word=%v", exact, prefix, word)
|
||||
}
|
||||
|
||||
if exact != indexRelExact {
|
||||
t.Errorf("exact relevance = %v, want %v", exact, indexRelExact)
|
||||
}
|
||||
|
||||
// A non-matching title still gets the floor (FTS matched something).
|
||||
if none != indexRelevanceFloor {
|
||||
t.Errorf("non-match relevance = %v, want floor %v", none, indexRelevanceFloor)
|
||||
}
|
||||
|
||||
// Artist-credit match should count when the title doesn't.
|
||||
artistMatch := indexHitRelevance("the beatles", "Abbey Road", "The Beatles")
|
||||
if artistMatch != indexRelExact {
|
||||
t.Errorf("artist-credit exact match = %v, want %v", artistMatch, indexRelExact)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
@@ -18,6 +19,12 @@ const (
|
||||
// cacheTTLEntity is the TTL for lookup/browse results (entity data
|
||||
// changes rarely).
|
||||
cacheTTLEntity = 7 * 24 * time.Hour
|
||||
// cacheTTLReleases is the TTL for a release group's releases +
|
||||
// tracklists. This data is effectively immutable once published, so
|
||||
// it's cached far longer than other entities: it's the local store
|
||||
// that keeps an album page's cold fetch a once-per-quarter event
|
||||
// rather than a weekly one.
|
||||
cacheTTLReleases = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// MusicBrainzClient wraps the musicbrainzws2 library with a local
|
||||
@@ -360,15 +367,27 @@ func (c *MusicBrainzClient) LookupRelease(
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// BrowseReleases fetches the releases for a given release group
|
||||
// MBID, including media/track information. Cached for 7 days.
|
||||
func (c *MusicBrainzClient) BrowseReleases(
|
||||
ctx context.Context, releaseGroupMBID string,
|
||||
) ([]MBRelease, error) {
|
||||
cacheKey := "mb:browse:releases:" + releaseGroupMBID
|
||||
// MBRecordingRelease is a slim reference to one release a recording
|
||||
// appears on — enough for the autotagger to pick a representative
|
||||
// release and then LookupRelease it in full.
|
||||
type MBRecordingRelease struct {
|
||||
MBID string `json:"mbid"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// LookupRecordingReleases fetches the releases a recording appears on
|
||||
// (id / title / status / date only). Used by the autotag recording-
|
||||
// search path to resolve a picked recording to a concrete release.
|
||||
// Cached for 7 days.
|
||||
func (c *MusicBrainzClient) LookupRecordingReleases(
|
||||
ctx context.Context, recordingMBID string,
|
||||
) ([]MBRecordingRelease, error) {
|
||||
cacheKey := "mb:lookup:recording-releases:" + recordingMBID
|
||||
|
||||
if data, ok := c.cache.Get(cacheKey); ok {
|
||||
var out []MBRelease
|
||||
var out []MBRecordingRelease
|
||||
if err := json.Unmarshal(data, &out); err == nil {
|
||||
return out, nil
|
||||
}
|
||||
@@ -378,6 +397,77 @@ func (c *MusicBrainzClient) BrowseReleases(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.logger.Info("musicbrainz lookup recording releases", "mbid", recordingMBID)
|
||||
|
||||
rec, err := c.mb.LookupRecording(
|
||||
ctx,
|
||||
mbtypes.MBID(recordingMBID),
|
||||
musicbrainzws2.IncludesFilter{Includes: []string{"releases"}},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]MBRecordingRelease, 0, len(rec.Releases))
|
||||
for _, rel := range rec.Releases {
|
||||
out = append(out, MBRecordingRelease{
|
||||
MBID: string(rel.ID),
|
||||
Title: rel.Title,
|
||||
Status: rel.Status,
|
||||
Date: rel.Date.String(),
|
||||
})
|
||||
}
|
||||
|
||||
c.cacheJSON(cacheKey, out, cacheTTLEntity, recordingMBID, "recording")
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BrowseReleases fetches the releases for a given release group
|
||||
// MBID, including media/track information. Cached for 7 days.
|
||||
// browseReleasesCacheKey returns the response-cache key for a release
|
||||
// group's releases.
|
||||
func browseReleasesCacheKey(releaseGroupMBID string) string {
|
||||
return "mb:browse:releases:" + releaseGroupMBID
|
||||
}
|
||||
|
||||
// BrowseReleasesCached returns a release group's releases from the local
|
||||
// response cache only, never hitting the network. The bool reports
|
||||
// whether a fresh (unexpired) cache entry was found. Used by the album
|
||||
// page's local-first path so a cold fetch can be deferred to the
|
||||
// background instead of blocking the request.
|
||||
func (c *MusicBrainzClient) BrowseReleasesCached(
|
||||
releaseGroupMBID string,
|
||||
) ([]MBRelease, bool) {
|
||||
data, ok := c.cache.Get(browseReleasesCacheKey(releaseGroupMBID))
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var out []MBRelease
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return out, true
|
||||
}
|
||||
|
||||
// BrowseReleases fetches all releases (with recordings + media) for a
|
||||
// release group, serving from the local response cache when warm and
|
||||
// otherwise hitting MusicBrainz and caching the result.
|
||||
func (c *MusicBrainzClient) BrowseReleases(
|
||||
ctx context.Context, releaseGroupMBID string,
|
||||
) ([]MBRelease, error) {
|
||||
cacheKey := browseReleasesCacheKey(releaseGroupMBID)
|
||||
|
||||
if out, ok := c.BrowseReleasesCached(releaseGroupMBID); ok {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.logger.Info("musicbrainz browse releases",
|
||||
"releaseGroupMBID", releaseGroupMBID,
|
||||
)
|
||||
@@ -395,7 +485,7 @@ func (c *MusicBrainzClient) BrowseReleases(
|
||||
|
||||
out := convertReleases(result.Releases)
|
||||
|
||||
c.cacheJSON(cacheKey, out, cacheTTLEntity, releaseGroupMBID, "release-group")
|
||||
c.cacheJSON(cacheKey, out, cacheTTLReleases, releaseGroupMBID, "release-group")
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -534,7 +624,19 @@ func convertRelease(r musicbrainzws2.Release) MBRelease {
|
||||
}
|
||||
|
||||
for _, m := range r.Media {
|
||||
// Skip video media outright — DVD/Blu-ray bonus discs
|
||||
// inflate track counts and wreck track-count-based scoring
|
||||
// (beets ignores video/data tracks for the same reason).
|
||||
if isVideoFormat(m.Format) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, t := range m.Tracks {
|
||||
// Same for individual video recordings on audio media.
|
||||
if t.Recording.IsVideo {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the recording MBID, not the track MBID. Tracks
|
||||
// and recordings have distinct MBIDs in MusicBrainz:
|
||||
// a track is the placement of a recording on a specific
|
||||
@@ -565,6 +667,25 @@ func convertRelease(r musicbrainzws2.Release) MBRelease {
|
||||
return rel
|
||||
}
|
||||
|
||||
// isVideoFormat reports whether a medium's format string names a
|
||||
// video carrier. "DVD-Audio" stays audio; bare "DVD", "DVD-Video",
|
||||
// "Blu-ray", "HD-DVD", "VHS", "VCD"/"SVCD" are video.
|
||||
func isVideoFormat(format string) bool {
|
||||
f := strings.ToLower(format)
|
||||
|
||||
if strings.Contains(f, "dvd-audio") {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, v := range []string{"dvd", "blu-ray", "bluray", "hd-dvd", "vhs", "vcd"} {
|
||||
if strings.Contains(f, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func convertReleases(releases []musicbrainzws2.Release) []MBRelease {
|
||||
out := make([]MBRelease, len(releases))
|
||||
for i, r := range releases {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// This file sketches a learned ranking model that replaces the
|
||||
// hand-tuned scoring constants (the fw* feature weights, tierBonus,
|
||||
// rgTierBonus, the intent-prior multipliers) with weights that can be
|
||||
// trained from the user's own click history.
|
||||
//
|
||||
// Status: NOT yet wired into Search(). The integration point is the
|
||||
// candidate scorers in the top-results pipeline
|
||||
// (scoreArtistCandidate / scoreReleaseGroupCandidate /
|
||||
// scoreRecordingCandidate) and the main rerank. Swap those additive
|
||||
// constant sums for RankFeatures + LinearModel.Score once the eval
|
||||
// harness has real fixtures to prove the change is a win.
|
||||
//
|
||||
// Why a linear model and not something fancier: it needs no scale, no
|
||||
// GPU, trains in microseconds on a desktop's worth of clicks, and —
|
||||
// crucially — personalises to ONE user. That is the lever a
|
||||
// multi-million-user system gets from aggregate logs; we get the same
|
||||
// shape of signal from a single user's repeated intent.
|
||||
//
|
||||
// Training prerequisite (important): logistic training needs negatives,
|
||||
// i.e. results that were SHOWN but NOT clicked. search_clicks records
|
||||
// only clicks today. To train properly, log impressions too (the
|
||||
// MBIDs shown for a query); clicked rows are positive labels, the rest
|
||||
// of the shown set are negatives. Until impressions are logged, use
|
||||
// DefaultModel(), whose weights reproduce the current behaviour.
|
||||
|
||||
// RankFeatures is the feature vector for a single candidate. Every
|
||||
// field is normalised to roughly [0,1] so weights are comparable.
|
||||
type RankFeatures struct {
|
||||
// Textual match strength against the query (mutually exclusive
|
||||
// tiers collapsed to a single 0..1 magnitude: exact=1.0,
|
||||
// prefix=0.6, whole-word=0.4, substring=0.2, none=0).
|
||||
NameMatch float64
|
||||
|
||||
// Artist-credit match strength, same scale. Lets "abbey road
|
||||
// beatles" reward the album credited to The Beatles.
|
||||
ArtistMatch float64
|
||||
|
||||
// Log-scaled popularity and listener count, both via normLog so
|
||||
// they share the fixed reference scale.
|
||||
LogPopularity float64
|
||||
LogListeners float64
|
||||
|
||||
// Personalisation signals.
|
||||
InLibrary float64 // 1.0 when owned, else 0
|
||||
IsSimilar float64 // 0..1 similarity to an owned artist
|
||||
|
||||
// Recency-decayed per-query click signal for this candidate.
|
||||
ClickRate float64
|
||||
}
|
||||
|
||||
// rankFeatureCount is the number of features (excluding bias). Used by
|
||||
// the gradient step to iterate fields generically.
|
||||
const rankFeatureCount = 7
|
||||
|
||||
// asSlice returns the features in a stable order so Score and Update
|
||||
// agree on indexing.
|
||||
func (f RankFeatures) asSlice() [rankFeatureCount]float64 {
|
||||
return [rankFeatureCount]float64{
|
||||
f.NameMatch,
|
||||
f.ArtistMatch,
|
||||
f.LogPopularity,
|
||||
f.LogListeners,
|
||||
f.InLibrary,
|
||||
f.IsSimilar,
|
||||
f.ClickRate,
|
||||
}
|
||||
}
|
||||
|
||||
// LinearModel scores a candidate as bias + Σ wᵢ·featureᵢ. For ranking
|
||||
// the raw score is what matters; the logistic squashing is used only
|
||||
// during training to produce a probability for the gradient.
|
||||
type LinearModel struct {
|
||||
Bias float64 `json:"bias"`
|
||||
Weights [rankFeatureCount]float64 `json:"weights"`
|
||||
}
|
||||
|
||||
// DefaultModel returns weights that reproduce the current hand-tuned
|
||||
// scorer, so swapping the model in with no training leaves behaviour
|
||||
// unchanged. The values mirror the fw* constants in explore.go.
|
||||
func DefaultModel() LinearModel {
|
||||
return LinearModel{
|
||||
Bias: 0,
|
||||
Weights: [rankFeatureCount]float64{
|
||||
1.00, // NameMatch ~ fwExactTitle / fwPrefixTitle blend
|
||||
0.90, // ArtistMatch ~ fwExactArtist
|
||||
0.80, // LogPopularity ~ fwListenLog
|
||||
0.60, // LogListeners ~ fwListenerLog
|
||||
0.50, // InLibrary ~ fwInLibrary
|
||||
0.20, // IsSimilar ~ fwSimilar
|
||||
0.30, // ClickRate ~ click feature cap
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Score returns the un-squashed ranking score for a candidate. Higher
|
||||
// is better. This is the value to sort by.
|
||||
func (m LinearModel) Score(f RankFeatures) float64 {
|
||||
score := m.Bias
|
||||
fs := f.asSlice()
|
||||
|
||||
for i := range fs {
|
||||
score += m.Weights[i] * fs[i]
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// Probability squashes the score to (0,1) via the logistic function.
|
||||
// Used during training to compute the gradient.
|
||||
func (m LinearModel) Probability(f RankFeatures) float64 {
|
||||
return 1.0 / (1.0 + math.Exp(-m.Score(f)))
|
||||
}
|
||||
|
||||
// Sample is one training example: a candidate's features and whether
|
||||
// the user clicked it (1.0) or saw-but-skipped it (0.0).
|
||||
type Sample struct {
|
||||
Features RankFeatures
|
||||
Label float64
|
||||
}
|
||||
|
||||
// Update performs one logistic-regression SGD step toward the label.
|
||||
// learningRate is typically ~0.05. Returns the pre-update prediction
|
||||
// so callers can track convergence.
|
||||
func (m *LinearModel) Update(s Sample, learningRate float64) float64 {
|
||||
pred := m.Probability(s.Features)
|
||||
err := s.Label - pred
|
||||
fs := s.Features.asSlice()
|
||||
|
||||
m.Bias += learningRate * err
|
||||
|
||||
for i := range fs {
|
||||
m.Weights[i] += learningRate * err * fs[i]
|
||||
}
|
||||
|
||||
return pred
|
||||
}
|
||||
|
||||
// Train runs SGD over the samples for the given number of epochs. A
|
||||
// few hundred clicks over a handful of epochs converges fine; this is
|
||||
// cheap enough to run on startup or after a search session.
|
||||
func Train(model *LinearModel, samples []Sample, epochs int, learningRate float64) {
|
||||
for range epochs {
|
||||
for _, s := range samples {
|
||||
model.Update(s, learningRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matchStrength collapses the mutually-exclusive textual match tiers
|
||||
// used throughout the current scorers into a single magnitude, so the
|
||||
// model has one weight to learn instead of four overlapping constants.
|
||||
func matchStrength(exact, prefix, wholeWord, substring bool) float64 {
|
||||
switch {
|
||||
case exact:
|
||||
return 1.0
|
||||
case prefix:
|
||||
return 0.6
|
||||
case wholeWord:
|
||||
return 0.4
|
||||
case substring:
|
||||
return 0.2
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
// SaveModel writes the model as JSON. Callers persist this to disk or
|
||||
// the index meta table; the model is small (8 floats).
|
||||
func SaveModel(w io.Writer, m LinearModel) error {
|
||||
if err := json.NewEncoder(w).Encode(m); err != nil {
|
||||
return fmt.Errorf("ranker: encode model: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadModel reads a model previously written by SaveModel.
|
||||
func LoadModel(r io.Reader) (LinearModel, error) {
|
||||
var m LinearModel
|
||||
|
||||
if err := json.NewDecoder(r).Decode(&m); err != nil {
|
||||
return LinearModel{}, fmt.Errorf("ranker: decode model: %w", err)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultModelScoreOrdering(t *testing.T) {
|
||||
m := DefaultModel()
|
||||
|
||||
// A popular, in-library exact match must outscore an obscure
|
||||
// substring match.
|
||||
strong := RankFeatures{NameMatch: 1.0, LogPopularity: 0.9, InLibrary: 1.0}
|
||||
weak := RankFeatures{NameMatch: 0.2, LogPopularity: 0.1}
|
||||
|
||||
if m.Score(strong) <= m.Score(weak) {
|
||||
t.Errorf("strong candidate %.3f should outscore weak %.3f",
|
||||
m.Score(strong), m.Score(weak))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMovesTowardLabel(t *testing.T) {
|
||||
m := DefaultModel()
|
||||
|
||||
// A candidate the user repeatedly clicks should see its predicted
|
||||
// probability rise after training on positive labels.
|
||||
f := RankFeatures{NameMatch: 0.4, LogPopularity: 0.2}
|
||||
|
||||
before := m.Probability(f)
|
||||
|
||||
for range 50 {
|
||||
m.Update(Sample{Features: f, Label: 1.0}, 0.1)
|
||||
}
|
||||
|
||||
after := m.Probability(f)
|
||||
|
||||
if after <= before {
|
||||
t.Errorf("probability should rise toward positive label: before=%.4f after=%.4f",
|
||||
before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLearnsNegative(t *testing.T) {
|
||||
m := DefaultModel()
|
||||
|
||||
f := RankFeatures{NameMatch: 0.9, LogPopularity: 0.9}
|
||||
|
||||
before := m.Probability(f)
|
||||
|
||||
// Shown repeatedly, never clicked — probability should fall.
|
||||
for range 50 {
|
||||
m.Update(Sample{Features: f, Label: 0.0}, 0.1)
|
||||
}
|
||||
|
||||
after := m.Probability(f)
|
||||
|
||||
if after >= before {
|
||||
t.Errorf("probability should fall toward negative label: before=%.4f after=%.4f",
|
||||
before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchStrengthTiers(t *testing.T) {
|
||||
if matchStrength(true, false, false, false) != 1.0 {
|
||||
t.Error("exact match should be 1.0")
|
||||
}
|
||||
|
||||
if matchStrength(false, false, false, false) != 0.0 {
|
||||
t.Error("no match should be 0.0")
|
||||
}
|
||||
|
||||
// Tiers must be strictly ordered.
|
||||
exact := matchStrength(true, false, false, false)
|
||||
prefix := matchStrength(false, true, false, false)
|
||||
word := matchStrength(false, false, true, false)
|
||||
sub := matchStrength(false, false, false, true)
|
||||
|
||||
if !(exact > prefix && prefix > word && word > sub) {
|
||||
t.Errorf("tiers not strictly ordered: %v %v %v %v", exact, prefix, word, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRoundTrip(t *testing.T) {
|
||||
m := DefaultModel()
|
||||
m.Bias = 0.123
|
||||
m.Weights[0] = 0.777
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := SaveModel(&buf, m); err != nil {
|
||||
t.Fatalf("SaveModel: %v", err)
|
||||
}
|
||||
|
||||
got, err := LoadModel(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadModel: %v", err)
|
||||
}
|
||||
|
||||
if math.Abs(got.Bias-m.Bias) > 1e-9 || math.Abs(got.Weights[0]-m.Weights[0]) > 1e-9 {
|
||||
t.Errorf("round trip mismatch: got %+v want %+v", got, m)
|
||||
}
|
||||
}
|
||||
+927
-1408
File diff suppressed because it is too large
Load Diff
+31
-13
@@ -24,6 +24,7 @@ type TopResult struct {
|
||||
MBID string `json:"mbid"`
|
||||
Name string `json:"name"`
|
||||
ArtistCredit string `json:"artistCredit,omitempty"` // for tracks/albums
|
||||
ArtistMBID string `json:"artistMbid,omitempty"` // for linking the artist subtitle
|
||||
IntentScore float64 `json:"intentScore"`
|
||||
// Artist-specific
|
||||
ArtistType string `json:"artistType,omitempty"` // "Group", "Person"
|
||||
@@ -31,8 +32,14 @@ type TopResult struct {
|
||||
// Album-specific
|
||||
PrimaryType string `json:"primaryType,omitempty"`
|
||||
Year string `json:"year,omitempty"`
|
||||
// Track-specific
|
||||
Length int `json:"length,omitempty"`
|
||||
// Track-specific. ReleaseGroupMBID is resolved (from CAAReleaseMBID)
|
||||
// so a track click can open its album page with the track highlighted,
|
||||
// matching how tracks behave everywhere else. ReleaseName is the album
|
||||
// title used for the album page header.
|
||||
Length int `json:"length,omitempty"`
|
||||
CAAReleaseMBID string `json:"caaReleaseMbid,omitempty"`
|
||||
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
||||
ReleaseName string `json:"releaseName,omitempty"`
|
||||
// Library status — populated from index cross-reference columns.
|
||||
InLibrary bool `json:"inLibrary"`
|
||||
}
|
||||
@@ -64,8 +71,9 @@ type MBReleaseGroup struct {
|
||||
SecondaryTypes []string `json:"secondaryTypes,omitempty"`
|
||||
FirstReleaseDate string `json:"firstReleaseDate"`
|
||||
ArtistCredit string `json:"artistCredit"`
|
||||
Score int `json:"-"` // MB search relevance, used for reranking
|
||||
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
|
||||
ArtistMBID string `json:"artistMbid,omitempty"` // for linking the artist to its detail page
|
||||
Score int `json:"-"` // MB search relevance, used for reranking
|
||||
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
|
||||
ListenerCount int `json:"listenerCount"`
|
||||
InLibrary bool `json:"inLibrary"` // true if the user owns this album
|
||||
LocalID int64 `json:"localId,omitempty"` // local release_group row ID
|
||||
@@ -85,15 +93,22 @@ type MBRelease struct {
|
||||
// MBRecording is a Wails-friendly projection of a MusicBrainz
|
||||
// recording.
|
||||
type MBRecording struct {
|
||||
MBID string `json:"mbid"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
ArtistCredit string `json:"artistCredit"`
|
||||
Score int `json:"score"`
|
||||
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
|
||||
ListenerCount int `json:"listenerCount"`
|
||||
InLibrary bool `json:"inLibrary"` // true if the user owns this recording
|
||||
LocalID int64 `json:"localId,omitempty"` // local recording row ID
|
||||
MBID string `json:"mbid"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
ArtistCredit string `json:"artistCredit"`
|
||||
ArtistMBID string `json:"artistMbid,omitempty"` // for linking the artist to its detail page
|
||||
Score int `json:"score"`
|
||||
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
|
||||
ListenerCount int `json:"listenerCount"`
|
||||
CAAReleaseMBID string `json:"caaReleaseMbid,omitempty"` // parent release, for album navigation
|
||||
// ReleaseGroupMBID is resolved from CAAReleaseMBID so a track can
|
||||
// link to its album page with the track highlighted, matching how
|
||||
// tracks behave everywhere else.
|
||||
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
||||
ReleaseName string `json:"releaseName,omitempty"` // album title
|
||||
InLibrary bool `json:"inLibrary"` // true if the user owns this recording
|
||||
LocalID int64 `json:"localId,omitempty"` // local recording row ID
|
||||
}
|
||||
|
||||
// MBTrack is a Wails-friendly projection of a MusicBrainz track.
|
||||
@@ -119,6 +134,9 @@ type LBTopRecording struct {
|
||||
TrackName string `json:"trackName"`
|
||||
TotalListenCount int `json:"totalListenCount"`
|
||||
CAAReleaseMBID string `json:"caaReleaseMbid"`
|
||||
// ReleaseGroupMBID is resolved from CAAReleaseMBID so a top-track
|
||||
// row can link to its album page with the track highlighted.
|
||||
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
||||
ReleaseName string `json:"releaseName"`
|
||||
Length int `json:"length"` // milliseconds (from LB API)
|
||||
InLibrary bool `json:"inLibrary"`
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/metadata"
|
||||
)
|
||||
|
||||
// featuringSeparators are the credit join phrases that introduce a
|
||||
// featured (non-primary) artist. Only true "featuring" markers are
|
||||
// listed: separators like "&", "x", "with", and "," are deliberately
|
||||
// excluded because they routinely appear inside real artist names
|
||||
// (e.g. "Simon & Garfunkel", "Tyler, the Creator").
|
||||
var featuringSeparators = []string{
|
||||
" feat. ", " feat ", " featuring ", " ft. ", " ft ",
|
||||
}
|
||||
|
||||
// stripFeaturing returns the credit up to its first "featuring" marker,
|
||||
// yielding the primary-artist portion of a credit string. "Lana Del
|
||||
// Rey ft. Sean Lennon" becomes "Lana Del Rey"; a credit with no marker
|
||||
// is returned unchanged (trimmed). Matching is case-insensitive.
|
||||
func stripFeaturing(credit string) string {
|
||||
lower := strings.ToLower(credit)
|
||||
|
||||
cut := -1
|
||||
|
||||
for _, sep := range featuringSeparators {
|
||||
if i := strings.Index(lower, sep); i >= 0 && (cut < 0 || i < cut) {
|
||||
cut = i
|
||||
}
|
||||
}
|
||||
|
||||
if cut < 0 {
|
||||
return strings.TrimSpace(credit)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(credit[:cut])
|
||||
}
|
||||
|
||||
// primaryArtist resolves the single canonical artist a track credit
|
||||
// should map to, plus that artist's MusicBrainz ID. A file tags its
|
||||
// ARTIST as a full credit string ("Lana Del Rey ft. Sean Lennon") but
|
||||
// carries only one MUSICBRAINZ_ARTISTID — the primary artist's. Storing
|
||||
// the whole credit as an artist entity, and stamping the primary MBID on
|
||||
// it, is what produced duplicate, mis-titled artists (one MBID fanned
|
||||
// out across many rows); instead we resolve the primary artist's clean
|
||||
// name here and keep the full credit only as the artist_credit text.
|
||||
//
|
||||
// The clean name comes from the album-artist tag when the track resolves
|
||||
// to the same MBID as the album artist (the common "Album Artist feat.
|
||||
// Guest" case, where ALBUMARTIST is the authoritative name). Otherwise
|
||||
// the featured clause is stripped from the credit string.
|
||||
func primaryArtist(tags *metadata.TrackMetadata) (name, mbid string) {
|
||||
mbid = tags.ArtistMBID
|
||||
if mbid == "" {
|
||||
mbid = tags.AlbumArtistMBID
|
||||
}
|
||||
|
||||
if tags.ArtistMBID != "" &&
|
||||
tags.ArtistMBID == tags.AlbumArtistMBID &&
|
||||
tags.AlbumArtist != "" {
|
||||
name = tags.AlbumArtist
|
||||
} else {
|
||||
name = stripFeaturing(tags.Artist)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = "Unknown Artist"
|
||||
}
|
||||
|
||||
return name, mbid
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/metadata"
|
||||
)
|
||||
|
||||
func TestStripFeaturing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
credit string
|
||||
want string
|
||||
}{
|
||||
{"plain", "Lana Del Rey", "Lana Del Rey"},
|
||||
{"ft dot", "Lana Del Rey ft. Sean Lennon", "Lana Del Rey"},
|
||||
{"feat dot", "2Pac feat. Nate Dogg", "2Pac"},
|
||||
{"featuring", "Beyoncé featuring The Weeknd", "Beyoncé"},
|
||||
{"ft no dot", "Drake ft Travis Scott", "Drake"},
|
||||
{"case insensitive", "Kanye West FEAT. PARTYNEXTDOOR", "Kanye West"},
|
||||
{"ampersand kept", "Simon & Garfunkel", "Simon & Garfunkel"},
|
||||
{"comma kept", "Tyler, the Creator", "Tyler, the Creator"},
|
||||
{"first marker wins", "A feat. B ft. C", "A"},
|
||||
{"trims", " Daft Punk feat. Panda Bear ", "Daft Punk"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := stripFeaturing(tt.credit); got != tt.want {
|
||||
t.Errorf("stripFeaturing(%q) = %q, want %q", tt.credit, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryArtist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const lana = "b7539c32-53e7-4908-bda3-81449c367da6"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tags metadata.TrackMetadata
|
||||
wantName string
|
||||
wantMBID string
|
||||
}{
|
||||
{
|
||||
name: "collab on own album uses clean album artist",
|
||||
tags: metadata.TrackMetadata{
|
||||
Artist: "Lana Del Rey ft. Sean Lennon",
|
||||
AlbumArtist: "Lana Del Rey",
|
||||
ArtistMBID: lana,
|
||||
AlbumArtistMBID: lana,
|
||||
},
|
||||
wantName: "Lana Del Rey",
|
||||
wantMBID: lana,
|
||||
},
|
||||
{
|
||||
name: "solo track",
|
||||
tags: metadata.TrackMetadata{
|
||||
Artist: "Lana Del Rey",
|
||||
AlbumArtist: "Lana Del Rey",
|
||||
ArtistMBID: lana,
|
||||
AlbumArtistMBID: lana,
|
||||
},
|
||||
wantName: "Lana Del Rey",
|
||||
wantMBID: lana,
|
||||
},
|
||||
{
|
||||
name: "compilation: album artist differs, strip featuring, keep track mbid",
|
||||
tags: metadata.TrackMetadata{
|
||||
Artist: "Some Artist feat. Guest",
|
||||
AlbumArtist: "Various Artists",
|
||||
ArtistMBID: "aaaa",
|
||||
AlbumArtistMBID: "va-mbid",
|
||||
},
|
||||
wantName: "Some Artist",
|
||||
wantMBID: "aaaa",
|
||||
},
|
||||
{
|
||||
name: "no album artist, strip featuring",
|
||||
tags: metadata.TrackMetadata{
|
||||
Artist: "Some Artist feat. Guest",
|
||||
ArtistMBID: "aaaa",
|
||||
},
|
||||
wantName: "Some Artist",
|
||||
wantMBID: "aaaa",
|
||||
},
|
||||
{
|
||||
name: "no track mbid falls back to album mbid",
|
||||
tags: metadata.TrackMetadata{
|
||||
Artist: "Solo",
|
||||
AlbumArtist: "Solo",
|
||||
AlbumArtistMBID: "album-mbid",
|
||||
},
|
||||
wantName: "Solo",
|
||||
wantMBID: "album-mbid",
|
||||
},
|
||||
{
|
||||
name: "empty artist",
|
||||
tags: metadata.TrackMetadata{},
|
||||
wantName: "Unknown Artist",
|
||||
wantMBID: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotName, gotMBID := primaryArtist(&tt.tags)
|
||||
if gotName != tt.wantName {
|
||||
t.Errorf("primaryArtist name = %q, want %q", gotName, tt.wantName)
|
||||
}
|
||||
|
||||
if gotMBID != tt.wantMBID {
|
||||
t.Errorf("primaryArtist mbid = %q, want %q", gotMBID, tt.wantMBID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ type RemovalHooks struct {
|
||||
StopPlayback func()
|
||||
// CompactQueue reloads queue state after cascade deletes.
|
||||
CompactQueue func()
|
||||
// PostRemove runs after the removal commits, for cross-cutting
|
||||
// invalidation (e.g. clearing library-sync "ready" markers).
|
||||
PostRemove func()
|
||||
}
|
||||
|
||||
// SetRemovalHooks provides optional hooks for cross-cutting
|
||||
@@ -461,6 +464,12 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
|
||||
l.removalHooks.CompactQueue()
|
||||
}
|
||||
|
||||
// 22. Post-commit: invalidate library-sync markers so the gated
|
||||
// index/lyric re-sync runs on the next launch.
|
||||
if l.removalHooks.PostRemove != nil {
|
||||
l.removalHooks.PostRemove()
|
||||
}
|
||||
|
||||
summary := &RemovalSummary{
|
||||
TracksDeleted: tracksDeleted,
|
||||
ArtistsRemoved: artistsRemoved,
|
||||
|
||||
+17
-14
@@ -1216,14 +1216,19 @@ func (l *Library) processMetadata(
|
||||
q, cache, metrics, tags, thumbChan,
|
||||
)
|
||||
|
||||
// 2. Get or create artist credit for track artist.
|
||||
artistName := tags.Artist
|
||||
if artistName == "" {
|
||||
artistName = "Unknown Artist"
|
||||
// 2. Get or create the artist credit for the track. The credit
|
||||
// text is the full tagged string (e.g. "Lana Del Rey ft. Sean
|
||||
// Lennon") and is kept only for display; the artist *entity* it
|
||||
// links to is the primary artist, resolved cleanly by primaryArtist
|
||||
// so featured-artist credits don't fork into their own bogus artist
|
||||
// rows (all sharing the primary's single MBID).
|
||||
creditText := tags.Artist
|
||||
if creditText == "" {
|
||||
creditText = "Unknown Artist"
|
||||
}
|
||||
|
||||
artistCredit, err := l.cachedUpsertArtistCredit(
|
||||
q, cache, artistName,
|
||||
q, cache, creditText,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
@@ -1231,7 +1236,9 @@ func (l *Library) processMetadata(
|
||||
)
|
||||
}
|
||||
|
||||
l.cachedLinkArtist(q, cache, metrics, artistName, artistCredit.ID)
|
||||
primaryName, primaryMBID := primaryArtist(tags)
|
||||
|
||||
l.cachedLinkArtist(q, cache, metrics, primaryName, artistCredit.ID)
|
||||
|
||||
// 3. Get or create artist credit for album artist.
|
||||
albumArtistCreditID := l.resolveAlbumArtistCredit(
|
||||
@@ -1289,9 +1296,9 @@ func (l *Library) processMetadata(
|
||||
|
||||
// 7. Update MusicBrainz IDs (if present in tags).
|
||||
if releaseGroupID.Valid {
|
||||
l.updateMBIDs(tx, cache, tags, artistName, releaseGroupID.Int64, recording.ID)
|
||||
l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, releaseGroupID.Int64, recording.ID)
|
||||
} else {
|
||||
l.updateMBIDs(tx, cache, tags, artistName, 0, recording.ID)
|
||||
l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, 0, recording.ID)
|
||||
}
|
||||
|
||||
return recording.ID, nil
|
||||
@@ -1306,15 +1313,11 @@ func (l *Library) updateMBIDs(
|
||||
cache *entityCache,
|
||||
tags *metadata.TrackMetadata,
|
||||
artistName string,
|
||||
artistMBID string,
|
||||
releaseGroupID int64,
|
||||
recordingID int64,
|
||||
) {
|
||||
// Artist MBID — prefer album artist, fall back to track artist.
|
||||
artistMBID := tags.AlbumArtistMBID
|
||||
if artistMBID == "" {
|
||||
artistMBID = tags.ArtistMBID
|
||||
}
|
||||
|
||||
// Artist MBID (the primary artist's, resolved by primaryArtist).
|
||||
if artistMBID != "" {
|
||||
if artist, ok := cache.artists[artistName]; ok {
|
||||
_, _ = tx.ExecContext(l.ctx,
|
||||
|
||||
+21
-16
@@ -183,6 +183,7 @@ type Album struct {
|
||||
ID int64
|
||||
Name string
|
||||
ArtistName string
|
||||
ArtistMBID string
|
||||
MBID string
|
||||
CoverArtPath string
|
||||
CoverArtSmall string
|
||||
@@ -194,7 +195,7 @@ type Album struct {
|
||||
|
||||
// GetAllTracks returns an array of track structs of every file in the library.
|
||||
func (l *Library) GetAllTracks() ([]Track, error) {
|
||||
rows, err := l.db.Queries.GetAllTracksWithFullMetadata(
|
||||
rows, err := l.db.ReadQueries.GetAllTracksWithFullMetadata(
|
||||
l.ctx,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -303,7 +304,7 @@ func (l *Library) SearchTracks(
|
||||
|
||||
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
|
||||
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
|
||||
rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
|
||||
rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
|
||||
if err != nil {
|
||||
l.logger.Error("could not retrieve album tracks", "albumID", albumID, "error", err)
|
||||
|
||||
@@ -347,7 +348,7 @@ func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
|
||||
|
||||
// GetAllAlbums returns all albums with cover art and artist info for the cover grid.
|
||||
func (l *Library) GetAllAlbums() ([]Album, error) {
|
||||
rows, err := l.db.Queries.GetAllAlbumsWithDetails(l.ctx)
|
||||
rows, err := l.db.ReadQueries.GetAllAlbumsWithDetails(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Error("could not retrieve albums", "error", err)
|
||||
|
||||
@@ -363,6 +364,7 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
ArtistName: row.ArtistName,
|
||||
ArtistMBID: row.ArtistMbid,
|
||||
}
|
||||
|
||||
if row.Year.Valid {
|
||||
@@ -392,7 +394,7 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
|
||||
|
||||
// GetAllArtists returns artists that are credited as album artists, ordered by name.
|
||||
func (l *Library) GetAllArtists() ([]Artist, error) {
|
||||
rows, err := l.db.Queries.GetAlbumArtists(l.ctx)
|
||||
rows, err := l.db.ReadQueries.GetAlbumArtists(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Error(
|
||||
"could not retrieve artists",
|
||||
@@ -489,7 +491,7 @@ func (l *Library) resolveArtistImages(artists []Artist) {
|
||||
func (l *Library) GetAlbumsByArtist(
|
||||
artistID int64,
|
||||
) ([]Album, error) {
|
||||
rows, err := l.db.Queries.GetAlbumsByArtist(
|
||||
rows, err := l.db.ReadQueries.GetAlbumsByArtist(
|
||||
l.ctx,
|
||||
artistID,
|
||||
)
|
||||
@@ -519,6 +521,7 @@ func (l *Library) GetAlbumsByArtist(
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
ArtistName: row.ArtistName,
|
||||
ArtistMBID: row.ArtistMbid,
|
||||
}
|
||||
|
||||
if row.Year.Valid {
|
||||
@@ -552,7 +555,7 @@ type GenreWithCount struct {
|
||||
func (l *Library) GetTracksByGenre(
|
||||
genreName string,
|
||||
) ([]Track, error) {
|
||||
rows, err := l.db.Queries.GetTracksByGenre(
|
||||
rows, err := l.db.ReadQueries.GetTracksByGenre(
|
||||
l.ctx, genreName,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -600,7 +603,7 @@ func (l *Library) GetTracksByGenre(
|
||||
func (l *Library) GetAllGenresWithCounts() (
|
||||
[]GenreWithCount, error,
|
||||
) {
|
||||
rows, err := l.db.Queries.GetAllGenresWithCounts(
|
||||
rows, err := l.db.ReadQueries.GetAllGenresWithCounts(
|
||||
l.ctx,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -630,7 +633,7 @@ func (l *Library) GetAllGenresWithCounts() (
|
||||
func (l *Library) GetAllTracksByLibrary(
|
||||
libraryID int64,
|
||||
) ([]Track, error) {
|
||||
rows, err := l.db.Queries.GetAllTracksWithFullMetadataByLibrary(
|
||||
rows, err := l.db.ReadQueries.GetAllTracksWithFullMetadataByLibrary(
|
||||
l.ctx, libraryID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -687,7 +690,7 @@ func (l *Library) GetAllTracksByLibrary(
|
||||
func (l *Library) GetAllAlbumsByLibrary(
|
||||
libraryID int64,
|
||||
) ([]Album, error) {
|
||||
rows, err := l.db.Queries.GetAllAlbumsWithDetailsByLibrary(
|
||||
rows, err := l.db.ReadQueries.GetAllAlbumsWithDetailsByLibrary(
|
||||
l.ctx, libraryID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -715,6 +718,7 @@ func (l *Library) GetAllAlbumsByLibrary(
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
ArtistName: row.ArtistName,
|
||||
ArtistMBID: row.ArtistMbid,
|
||||
}
|
||||
|
||||
if row.Year.Valid {
|
||||
@@ -746,7 +750,7 @@ func (l *Library) GetAllAlbumsByLibrary(
|
||||
func (l *Library) GetAllArtistsByLibrary(
|
||||
libraryID int64,
|
||||
) ([]Artist, error) {
|
||||
rows, err := l.db.Queries.GetAlbumArtistsByLibrary(
|
||||
rows, err := l.db.ReadQueries.GetAlbumArtistsByLibrary(
|
||||
l.ctx, libraryID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -792,7 +796,7 @@ func (l *Library) GetAllArtistsByLibrary(
|
||||
func (l *Library) GetAlbumsByArtistByLibrary(
|
||||
artistID, libraryID int64,
|
||||
) ([]Album, error) {
|
||||
rows, err := l.db.Queries.GetAlbumsByArtistByLibrary(
|
||||
rows, err := l.db.ReadQueries.GetAlbumsByArtistByLibrary(
|
||||
l.ctx, sqlcgen.GetAlbumsByArtistByLibraryParams{
|
||||
ArtistID: artistID,
|
||||
LibraryID: libraryID,
|
||||
@@ -826,6 +830,7 @@ func (l *Library) GetAlbumsByArtistByLibrary(
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
ArtistName: row.ArtistName,
|
||||
ArtistMBID: row.ArtistMbid,
|
||||
}
|
||||
|
||||
if row.Year.Valid {
|
||||
@@ -853,7 +858,7 @@ func (l *Library) GetAlbumsByArtistByLibrary(
|
||||
func (l *Library) GetAllGenresWithCountsByLibrary(
|
||||
libraryID int64,
|
||||
) ([]GenreWithCount, error) {
|
||||
rows, err := l.db.Queries.GetAllGenresWithCountsByLibrary(
|
||||
rows, err := l.db.ReadQueries.GetAllGenresWithCountsByLibrary(
|
||||
l.ctx, libraryID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -885,7 +890,7 @@ func (l *Library) GetAllGenresWithCountsByLibrary(
|
||||
func (l *Library) GetTracksByGenreByLibrary(
|
||||
genreName string, libraryID int64,
|
||||
) ([]Track, error) {
|
||||
rows, err := l.db.Queries.GetTracksByGenreByLibrary(
|
||||
rows, err := l.db.ReadQueries.GetTracksByGenreByLibrary(
|
||||
l.ctx, sqlcgen.GetTracksByGenreByLibraryParams{
|
||||
Name: genreName,
|
||||
LibraryID: libraryID,
|
||||
@@ -939,7 +944,7 @@ func (l *Library) GetTracksByGenreByLibrary(
|
||||
func (l *Library) GetAlbumTracksByLibrary(
|
||||
albumID, libraryID int64,
|
||||
) ([]Track, error) {
|
||||
rows, err := l.db.Queries.GetAudioFilesByReleaseGroupByLibrary(
|
||||
rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroupByLibrary(
|
||||
l.ctx, sqlcgen.GetAudioFilesByReleaseGroupByLibraryParams{
|
||||
ReleaseGroupID: albumID,
|
||||
LibraryID: libraryID,
|
||||
@@ -1052,7 +1057,7 @@ type Info struct {
|
||||
// GetAllLibrariesWithTrackCounts returns all libraries with their
|
||||
// audio file counts. Typically 1-5 libraries so the loop is trivial.
|
||||
func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) {
|
||||
libs, err := l.db.Queries.GetAllLibraries(l.ctx)
|
||||
libs, err := l.db.ReadQueries.GetAllLibraries(l.ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get libraries: %w", err)
|
||||
}
|
||||
@@ -1060,7 +1065,7 @@ func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) {
|
||||
result := make([]Info, 0, len(libs))
|
||||
|
||||
for _, lib := range libs {
|
||||
count, countErr := l.db.Queries.CountAudioFilesByLibrary(l.ctx, lib.ID)
|
||||
count, countErr := l.db.ReadQueries.CountAudioFilesByLibrary(l.ctx, lib.ID)
|
||||
if countErr != nil {
|
||||
l.logger.Error("could not count tracks for library",
|
||||
"libraryID", lib.ID, "error", countErr)
|
||||
|
||||
@@ -19,6 +19,12 @@ import (
|
||||
// temporarily empty (read-ahead hasn't caught up), Stream returns
|
||||
// silence rather than blocking or signaling end-of-stream.
|
||||
type BufferedStreamer struct {
|
||||
// srcMu serializes all access to the underlying source. The
|
||||
// read-ahead goroutine holds it while calling source.Stream;
|
||||
// callers that need to Seek the source must hold it too (via
|
||||
// LockSource/UnlockSource) so the non-thread-safe decoder is
|
||||
// never read and seeked concurrently.
|
||||
srcMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
source beep.Streamer
|
||||
ring [][2]float64
|
||||
@@ -89,9 +95,12 @@ func (bs *BufferedStreamer) readAhead() {
|
||||
|
||||
bs.mu.Unlock()
|
||||
|
||||
// Read from source WITHOUT holding the lock so disk I/O
|
||||
// does not block the speaker goroutine.
|
||||
// Read from source WITHOUT holding bs.mu so disk I/O does
|
||||
// not block the speaker goroutine. srcMu is held to keep
|
||||
// this read from racing a concurrent source Seek.
|
||||
bs.srcMu.Lock()
|
||||
n, ok := bs.source.Stream(tmp[:toRead])
|
||||
bs.srcMu.Unlock()
|
||||
|
||||
if n > 0 {
|
||||
bs.mu.Lock()
|
||||
@@ -190,6 +199,20 @@ func (bs *BufferedStreamer) Flush() {
|
||||
bs.count = 0
|
||||
}
|
||||
|
||||
// LockSource blocks the read-ahead goroutine from touching the
|
||||
// underlying source, giving the caller exclusive access so it can
|
||||
// safely Seek the non-thread-safe decoder. Every LockSource must
|
||||
// be paired with an UnlockSource.
|
||||
func (bs *BufferedStreamer) LockSource() {
|
||||
bs.srcMu.Lock()
|
||||
}
|
||||
|
||||
// UnlockSource releases the exclusive source access acquired by
|
||||
// LockSource, allowing the read-ahead goroutine to resume.
|
||||
func (bs *BufferedStreamer) UnlockSource() {
|
||||
bs.srcMu.Unlock()
|
||||
}
|
||||
|
||||
// Close signals the read-ahead goroutine to stop. It is safe to
|
||||
// call multiple times.
|
||||
func (bs *BufferedStreamer) Close() {
|
||||
|
||||
@@ -764,6 +764,17 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
||||
return fmt.Errorf("cannot get track length: %w", err)
|
||||
}
|
||||
|
||||
// Block the read-ahead goroutine from reading the source while
|
||||
// we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is
|
||||
// not safe for concurrent Read+Seek, and read-ahead runs on its
|
||||
// own goroutine — without this it can panic with a slice-bounds
|
||||
// error, especially right after load when the buffer is empty
|
||||
// and read-ahead is filling at full speed.
|
||||
if p.buffered != nil {
|
||||
p.buffered.LockSource()
|
||||
defer p.buffered.UnlockSource()
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
|
||||
samples := int(
|
||||
@@ -863,7 +874,7 @@ func (p *Player) getCurrentTrackInfoLocked() TrackInfo {
|
||||
|
||||
// Try to get metadata from database.
|
||||
if p.db != nil {
|
||||
meta, err := p.db.Queries.GetTrackMetadataByPath(
|
||||
meta, err := p.db.ReadQueries.GetTrackMetadataByPath(
|
||||
p.ctx, info.FilePath,
|
||||
)
|
||||
if err == nil {
|
||||
@@ -1004,7 +1015,7 @@ func (p *Player) buildMediaMetadata(
|
||||
// full path; ResolveURLs converts it to relative HTTP paths
|
||||
// for the frontend, but MPRIS needs the actual file path.
|
||||
if p.db != nil && info.FilePath != "" {
|
||||
dbMeta, err := p.db.Queries.GetTrackMetadataByPath(
|
||||
dbMeta, err := p.db.ReadQueries.GetTrackMetadataByPath(
|
||||
p.ctx, info.FilePath,
|
||||
)
|
||||
if err == nil && dbMeta.CoverArtPath != "" {
|
||||
@@ -1102,7 +1113,7 @@ func (p *Player) restoreStateLocked() {
|
||||
return
|
||||
}
|
||||
|
||||
state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
|
||||
state, err := p.db.ReadQueries.GetPlayerState(p.db.Ctx)
|
||||
if err != nil {
|
||||
p.logger.Error(
|
||||
"Failed to load player state", "err", err,
|
||||
|
||||
+152
-13
@@ -132,6 +132,11 @@ type Service struct {
|
||||
db *database.DB
|
||||
libraryDir LibraryDirProvider
|
||||
favoritesConf FavoritesConfigProvider
|
||||
|
||||
// dataDirOverride, when non-empty, replaces the OS user data
|
||||
// directory as the base for the playlists folder. Set by tests to
|
||||
// keep M3U writes out of the real user data directory.
|
||||
dataDirOverride string
|
||||
}
|
||||
|
||||
// NewService creates a new playlist service.
|
||||
@@ -172,7 +177,7 @@ func (s *Service) SetContext(ctx context.Context) {
|
||||
// GetAllPlaylists returns all playlists ordered by most recently
|
||||
// updated.
|
||||
func (s *Service) GetAllPlaylists() ([]Summary, error) {
|
||||
playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx)
|
||||
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to get playlists", "err", err,
|
||||
@@ -204,7 +209,7 @@ func (s *Service) GetAllPlaylistsWithTracks() (
|
||||
[]WithTracks,
|
||||
error,
|
||||
) {
|
||||
playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx)
|
||||
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to get playlists", "err", err,
|
||||
@@ -215,7 +220,7 @@ func (s *Service) GetAllPlaylistsWithTracks() (
|
||||
)
|
||||
}
|
||||
|
||||
rows, err := s.db.Queries.GetAllPlaylistTracksWithMetadata(
|
||||
rows, err := s.db.ReadQueries.GetAllPlaylistTracksWithMetadata(
|
||||
s.db.Ctx,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -288,7 +293,7 @@ func (s *Service) GetAllPlaylistsWithTracks() (
|
||||
func (s *Service) GetPlaylistTracks(
|
||||
playlistID int64,
|
||||
) ([]Track, error) {
|
||||
rows, err := s.db.Queries.GetPlaylistTracksWithMetadata(
|
||||
rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
|
||||
s.db.Ctx,
|
||||
playlistID,
|
||||
)
|
||||
@@ -563,7 +568,7 @@ func (s *Service) FindDuplicateTracksInPlaylist(
|
||||
playlistID int64,
|
||||
filePaths []string,
|
||||
) (DuplicateCheckResult, error) {
|
||||
rows, err := s.db.Queries.GetPlaylistTracksWithMetadata(
|
||||
rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
|
||||
s.db.Ctx,
|
||||
playlistID,
|
||||
)
|
||||
@@ -1229,11 +1234,17 @@ func (s *Service) addSingleTrack(
|
||||
// playlistsDir returns the path to the playlists directory,
|
||||
// creating it if needed.
|
||||
func (s *Service) playlistsDir() (string, error) {
|
||||
dataDir, err := system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"could not get user data directory: %w", err,
|
||||
)
|
||||
dataDir := s.dataDirOverride
|
||||
|
||||
if dataDir == "" {
|
||||
var err error
|
||||
|
||||
dataDir, err = system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"could not get user data directory: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dir := filepath.Join(dataDir, playlistsDirName)
|
||||
@@ -1376,7 +1387,7 @@ func (s *Service) saveImportedPlaylistFile(
|
||||
func (s *Service) buildM3UEntries(
|
||||
playlistID int64,
|
||||
) []m3uEntry {
|
||||
rows, err := s.db.Queries.GetPlaylistTracksWithMetadata(
|
||||
rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
|
||||
s.db.Ctx,
|
||||
playlistID,
|
||||
)
|
||||
@@ -1482,7 +1493,7 @@ func (s *Service) migrateExistingPlaylists() {
|
||||
}
|
||||
}
|
||||
|
||||
playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx)
|
||||
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"Could not get playlists for migration",
|
||||
@@ -1534,7 +1545,7 @@ func (s *Service) RepopulateFromM3U() {
|
||||
}
|
||||
|
||||
// Get all playlists.
|
||||
playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx)
|
||||
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("could not get playlists for repopulation", "err", err)
|
||||
|
||||
@@ -2677,11 +2688,139 @@ func (s *Service) UpdateSmartPlaylistRules(
|
||||
"playlistId", playlistID,
|
||||
)
|
||||
|
||||
// Re-materialize the persisted snapshot so it reflects the new
|
||||
// rules. RefreshSmartPlaylist emits PlaylistTracksChanged.
|
||||
if err := s.RefreshSmartPlaylist(playlistID); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to refresh smart playlist after rule update: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshSmartPlaylist re-evaluates a smart playlist's rules against
|
||||
// the current library and replaces its persisted membership in
|
||||
// playlist_tracks with the result. This is the only path that
|
||||
// re-evaluates a smart playlist — opening one otherwise reads the
|
||||
// stored snapshot. Triggered on rule save and by the manual Refresh
|
||||
// button.
|
||||
func (s *Service) RefreshSmartPlaylist(
|
||||
playlistID int64,
|
||||
) error {
|
||||
// EvaluateSmartPlaylist validates that the playlist exists and is
|
||||
// smart, and returns the live rule-matched tracks.
|
||||
tracks, err := s.EvaluateSmartPlaylist(playlistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Queries.ClearPlaylistTracks(
|
||||
s.db.Ctx, playlistID,
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to clear smart playlist tracks: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
for i, t := range tracks {
|
||||
if strings.TrimSpace(t.FilePath) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.addSingleTrack(
|
||||
playlistID, t.FilePath, int64(i),
|
||||
); err != nil {
|
||||
// A track can vanish between evaluation and insertion
|
||||
// (e.g. a concurrent rescan). Skip it rather than abort
|
||||
// the whole refresh.
|
||||
s.logger.Warn(
|
||||
"Skipping smart playlist track during refresh",
|
||||
"playlistId", playlistID,
|
||||
"filePath", t.FilePath,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: Hand-crafted UPDATE for smart_snapshot_at column not
|
||||
// yet in sqlc schema. Parameterized by playlist ID.
|
||||
if _, err := s.db.ExecContext(
|
||||
`UPDATE playlists
|
||||
SET smart_snapshot_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_smart = 1`,
|
||||
playlistID,
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to mark smart playlist snapshot: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Smart playlist refreshed",
|
||||
"playlistId", playlistID,
|
||||
"trackCount", len(tracks),
|
||||
)
|
||||
|
||||
s.savePlaylistFileByID(playlistID)
|
||||
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSmartPlaylistTracks returns the persisted snapshot of a smart
|
||||
// playlist as regular playlist tracks (with resolved cover art and
|
||||
// phantom entries), identical to a normal playlist. If the playlist
|
||||
// has never been materialized — e.g. it predates snapshot support —
|
||||
// it is evaluated and stored on first access.
|
||||
func (s *Service) GetSmartPlaylistTracks(
|
||||
playlistID int64,
|
||||
) ([]Track, error) {
|
||||
// SAFETY: Hand-crafted SELECT for smart_snapshot_at column not
|
||||
// yet in sqlc schema. Parameterized by playlist ID.
|
||||
rows, err := s.db.QueryContext(
|
||||
`SELECT smart_snapshot_at FROM playlists
|
||||
WHERE id = ? AND is_smart = 1`,
|
||||
playlistID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to load smart playlist: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if !rows.Next() {
|
||||
_ = rows.Close()
|
||||
|
||||
return nil, errNotSmartPlaylist
|
||||
}
|
||||
|
||||
var snapshotAt sql.NullString
|
||||
|
||||
if err := rows.Scan(&snapshotAt); err != nil {
|
||||
_ = rows.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read smart playlist snapshot state: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Close before RefreshSmartPlaylist / GetPlaylistTracks issue
|
||||
// their own queries (MaxOpenConns=1 test DBs would deadlock).
|
||||
_ = rows.Close()
|
||||
|
||||
if !snapshotAt.Valid {
|
||||
if err := s.RefreshSmartPlaylist(playlistID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetPlaylistTracks(playlistID)
|
||||
}
|
||||
|
||||
// EvaluateSmartPlaylist loads the rule set for a smart playlist
|
||||
// from the database and evaluates it against the track library,
|
||||
// returning the matching tracks.
|
||||
|
||||
@@ -170,8 +170,9 @@ func newTestService(t *testing.T, db *database.DB) *Service {
|
||||
t.Helper()
|
||||
|
||||
return &Service{
|
||||
db: db,
|
||||
logger: slog.Default(),
|
||||
db: db,
|
||||
logger: slog.Default(),
|
||||
dataDirOverride: t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +295,141 @@ func TestSmartPlaylistUpdateRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmartPlaylistPersistedSnapshot verifies that a smart playlist's
|
||||
// membership is materialized and read from a stored snapshot: it is
|
||||
// backfilled on first access, does NOT re-evaluate when the rules
|
||||
// change out from under it, and only re-materializes on an explicit
|
||||
// refresh.
|
||||
func TestSmartPlaylistPersistedSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
seedSmartTestTracks(t, db)
|
||||
|
||||
svc := newTestService(t, db)
|
||||
|
||||
// Band A → tracks 1 and 3.
|
||||
bandARules := makeRulesJSON(t, smartplaylist.RuleSet{
|
||||
Rules: []smartplaylist.Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Band A"},
|
||||
},
|
||||
})
|
||||
|
||||
summary, err := svc.CreateSmartPlaylist("Snapshot Test", bandARules)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSmartPlaylist failed: %v", err)
|
||||
}
|
||||
|
||||
// First access backfills the snapshot (snapshot_at was NULL).
|
||||
tracks, err := svc.GetSmartPlaylistTracks(summary.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSmartPlaylistTracks failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("initial snapshot: got %d tracks, want 2", len(tracks))
|
||||
}
|
||||
|
||||
for _, tr := range tracks {
|
||||
if tr.Artist != "Band A" {
|
||||
t.Errorf("snapshot track %q has artist %q, want Band A",
|
||||
tr.Title, tr.Artist)
|
||||
}
|
||||
}
|
||||
|
||||
// Change the rules directly in the DB, bypassing
|
||||
// UpdateSmartPlaylistRules so no refresh is triggered. The stored
|
||||
// snapshot must be unaffected.
|
||||
bandBRules := makeRulesJSON(t, smartplaylist.RuleSet{
|
||||
Rules: []smartplaylist.Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Band B"},
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE playlists SET smart_rules = ? WHERE id = ?",
|
||||
bandBRules, summary.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to rewrite rules: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot is served as-is: still Band A's two tracks, not Band B.
|
||||
tracks, err = svc.GetSmartPlaylistTracks(summary.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSmartPlaylistTracks (post rule change) failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("snapshot re-read: got %d tracks, want 2 (must not re-evaluate)",
|
||||
len(tracks))
|
||||
}
|
||||
|
||||
// An explicit refresh re-materializes against the current rules.
|
||||
if err := svc.RefreshSmartPlaylist(summary.ID); err != nil {
|
||||
t.Fatalf("RefreshSmartPlaylist failed: %v", err)
|
||||
}
|
||||
|
||||
tracks, err = svc.GetSmartPlaylistTracks(summary.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSmartPlaylistTracks (post refresh) failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("post-refresh snapshot: got %d tracks, want 1", len(tracks))
|
||||
}
|
||||
|
||||
if tracks[0].Artist != "Band B" {
|
||||
t.Errorf("post-refresh artist = %q, want Band B", tracks[0].Artist)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmartPlaylistSaveRulesRematerializes verifies that saving new
|
||||
// rules through UpdateSmartPlaylistRules refreshes the stored snapshot.
|
||||
func TestSmartPlaylistSaveRulesRematerializes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
seedSmartTestTracks(t, db)
|
||||
|
||||
svc := newTestService(t, db)
|
||||
|
||||
bandARules := makeRulesJSON(t, smartplaylist.RuleSet{
|
||||
Rules: []smartplaylist.Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Band A"},
|
||||
},
|
||||
})
|
||||
|
||||
summary, err := svc.CreateSmartPlaylist("Save Refresh", bandARules)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSmartPlaylist failed: %v", err)
|
||||
}
|
||||
|
||||
bandBRules := makeRulesJSON(t, smartplaylist.RuleSet{
|
||||
Rules: []smartplaylist.Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Band B"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := svc.UpdateSmartPlaylistRules(summary.ID, bandBRules); err != nil {
|
||||
t.Fatalf("UpdateSmartPlaylistRules failed: %v", err)
|
||||
}
|
||||
|
||||
// The stored snapshot should already reflect the new rules without
|
||||
// any manual refresh.
|
||||
tracks, err := svc.GetSmartPlaylistTracks(summary.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSmartPlaylistTracks failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("got %d tracks, want 1", len(tracks))
|
||||
}
|
||||
|
||||
if tracks[0].Artist != "Band B" {
|
||||
t.Errorf("artist = %q, want Band B", tracks[0].Artist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartPlaylistCreateInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ func (q *Queue) lookupChunk(
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := q.db.Queries.LookupTrackMetaByPaths(q.db.Ctx, paths)
|
||||
rows, err := q.db.ReadQueries.LookupTrackMetaByPaths(q.db.Ctx, paths)
|
||||
if err != nil {
|
||||
q.logger.Error("Batch metadata lookup failed", "err", err)
|
||||
|
||||
@@ -415,7 +415,7 @@ func (q *Queue) RestoreState() {
|
||||
defer q.mu.Unlock()
|
||||
|
||||
// Restore queue metadata.
|
||||
state, err := q.db.Queries.GetQueueState(q.db.Ctx)
|
||||
state, err := q.db.ReadQueries.GetQueueState(q.db.Ctx)
|
||||
if err != nil {
|
||||
q.logger.Error("Failed to load queue state", "err", err)
|
||||
|
||||
@@ -444,7 +444,7 @@ func (q *Queue) RestoreState() {
|
||||
}
|
||||
|
||||
// Restore queue tracks.
|
||||
rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx)
|
||||
rows, err := q.db.ReadQueries.GetQueueTracks(q.db.Ctx)
|
||||
if err != nil {
|
||||
q.logger.Error("Failed to load queue tracks", "err", err)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/coverart"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/library"
|
||||
)
|
||||
@@ -54,6 +55,7 @@ var fieldMap = map[string]string{
|
||||
"album": "album",
|
||||
"genre": "genre",
|
||||
"year": "year",
|
||||
"release_year": "release_year",
|
||||
"composer": "composer",
|
||||
"file_type": "file_type",
|
||||
"duration": "length_milliseconds",
|
||||
@@ -72,6 +74,7 @@ var fieldMap = map[string]string{
|
||||
// numericFields identifies fields that accept numeric operators.
|
||||
var numericFields = map[string]bool{
|
||||
"year": true,
|
||||
"release_year": true,
|
||||
"duration": true,
|
||||
"sample_rate": true,
|
||||
"bit_depth": true,
|
||||
@@ -552,7 +555,11 @@ const leanTrackQuery = `SELECT
|
||||
af.bitrate,
|
||||
af.file_size,
|
||||
af.play_count,
|
||||
COALESCE(af.last_played, '') AS last_played
|
||||
COALESCE(af.last_played, '') AS last_played,
|
||||
af.cover_art_path,
|
||||
af.artist_mbid,
|
||||
af.release_group_mbid,
|
||||
af.recording_mbid
|
||||
FROM (
|
||||
SELECT
|
||||
af.id,
|
||||
@@ -564,7 +571,17 @@ FROM (
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
-- Two year fields, matching the canonical track_metadata view:
|
||||
-- year — the album's original (first-release) year,
|
||||
-- the default users filter on. A 1977 album owned
|
||||
-- as a 2010s reissue still filters as 1977.
|
||||
-- release_year — the year of the specific release in the library
|
||||
-- (the file/release-group tag), e.g. 2013 for that
|
||||
-- reissue.
|
||||
-- Both fall back through rg.year → r.year so a track without full
|
||||
-- MusicBrainz data still gets a sensible year.
|
||||
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
|
||||
COALESCE(rg.year, r.year, 0) AS release_year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
@@ -574,7 +591,15 @@ FROM (
|
||||
af.file_size,
|
||||
af.library_id,
|
||||
af.play_count,
|
||||
af.last_played
|
||||
af.last_played,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||
COALESCE((SELECT a.mbid
|
||||
FROM artist_credit_artist aca
|
||||
JOIN artists a ON a.id = aca.artist_id
|
||||
WHERE aca.credit_id = ac.id
|
||||
LIMIT 1), '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
@@ -585,6 +610,7 @@ FROM (
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
) af`
|
||||
|
||||
@@ -752,6 +778,11 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
||||
fileSize int64
|
||||
playCount int64
|
||||
lastPlayed string
|
||||
|
||||
coverArtPath string
|
||||
artistMBID string
|
||||
releaseGroupMBID string
|
||||
recordingMBID string
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
@@ -761,31 +792,46 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
|
||||
&sampleRate, &bitDepth, &channels,
|
||||
&bitrate, &fileSize,
|
||||
&playCount, &lastPlayed,
|
||||
&coverArtPath, &artistMBID,
|
||||
&releaseGroupMBID, &recordingMBID,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"could not scan smart playlist row: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
tracks = append(tracks, library.Track{
|
||||
TrackName: title,
|
||||
ArtistName: artistName,
|
||||
TrackLength: strconv.FormatInt(lengthMs, 10),
|
||||
FilePath: filePath,
|
||||
TrackNumber: trackNumber.Int64,
|
||||
DiscNumber: discNumber.Int64,
|
||||
Album: album,
|
||||
Year: year,
|
||||
Composer: composer,
|
||||
FileType: fileType,
|
||||
SampleRate: sampleRate,
|
||||
BitDepth: bitDepth,
|
||||
Channels: channels,
|
||||
Bitrate: bitrate,
|
||||
FileSize: fileSize,
|
||||
PlayCount: playCount,
|
||||
LastPlayed: lastPlayed,
|
||||
})
|
||||
track := library.Track{
|
||||
TrackName: title,
|
||||
ArtistName: artistName,
|
||||
TrackLength: strconv.FormatInt(lengthMs, 10),
|
||||
FilePath: filePath,
|
||||
TrackNumber: trackNumber.Int64,
|
||||
DiscNumber: discNumber.Int64,
|
||||
Album: album,
|
||||
Year: year,
|
||||
Composer: composer,
|
||||
FileType: fileType,
|
||||
SampleRate: sampleRate,
|
||||
BitDepth: bitDepth,
|
||||
Channels: channels,
|
||||
Bitrate: bitrate,
|
||||
FileSize: fileSize,
|
||||
PlayCount: playCount,
|
||||
LastPlayed: lastPlayed,
|
||||
ArtistMBID: artistMBID,
|
||||
ReleaseGroupMBID: releaseGroupMBID,
|
||||
RecordingMBID: recordingMBID,
|
||||
}
|
||||
|
||||
if coverArtPath != "" {
|
||||
urls := coverart.ResolveURLs(coverArtPath)
|
||||
track.CoverArtPath = urls.Original
|
||||
track.CoverArtSmall = urls.Small
|
||||
track.CoverArtMedium = urls.Medium
|
||||
track.CoverArtLarge = urls.Large
|
||||
}
|
||||
|
||||
tracks = append(tracks, track)
|
||||
recordingIDs = append(recordingIDs, recordingID.Int64)
|
||||
}
|
||||
|
||||
|
||||
@@ -1618,3 +1618,120 @@ func TestEvaluate_MultiGenreTrackGenreField(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluate_YearUsesOriginalReleaseYear is a regression test for a
|
||||
// bug where the smart-playlist year filter tested recordings.year (the
|
||||
// file's ID3/reissue tag year) instead of the release group's original
|
||||
// first-release year, the way the canonical track_metadata view and the
|
||||
// UI do. That made a 1977 album owned as a 2010s reissue leak into a
|
||||
// "2010s" year filter even though it displays as 1977.
|
||||
func TestEvaluate_YearUsesOriginalReleaseYear(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
// One track: a 1977 album the user owns as a 2013 reissue. The file
|
||||
// tag / recording year is 2013, but the release group's original
|
||||
// (first-release) year is 1977.
|
||||
exec := func(query string, args ...any) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.ExecContext(query, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
exec("INSERT INTO artist_credit (id, text) VALUES (1, ?)", "The B-52's")
|
||||
exec(
|
||||
"INSERT INTO release_groups (id, name, year, original_year) "+
|
||||
"VALUES (1, ?, 2013, 1977)",
|
||||
"Reissue Compilation",
|
||||
)
|
||||
exec(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id, year) "+
|
||||
"VALUES (1, ?, 1, 2013)",
|
||||
"Rock Lobster",
|
||||
)
|
||||
exec(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, "+
|
||||
"file_type_id, recording_id) VALUES (1, ?, 300000, 1, 1)",
|
||||
"/music/b52s/rock_lobster.mp3",
|
||||
)
|
||||
exec(
|
||||
"INSERT INTO release_group_recordings " +
|
||||
"(release_group_id, recording_id) VALUES (1, 1)",
|
||||
)
|
||||
|
||||
// A "2010s" filter must NOT match — the album is originally from 1977.
|
||||
tracks, err := Evaluate(db, RuleSet{
|
||||
Rules: []Rule{
|
||||
{Field: "year", Operator: "between", Value: "2010,2019"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate 2010s: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 0 {
|
||||
t.Errorf(
|
||||
"2010s filter matched %d tracks, want 0 "+
|
||||
"(reissue year leaked in)", len(tracks),
|
||||
)
|
||||
}
|
||||
|
||||
// A "1970s" filter must match — original_year is 1977.
|
||||
tracks, err = Evaluate(db, RuleSet{
|
||||
Rules: []Rule{
|
||||
{Field: "year", Operator: "between", Value: "1970,1979"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate 1970s: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf(
|
||||
"1970s filter matched %d tracks, want 1", len(tracks),
|
||||
)
|
||||
}
|
||||
|
||||
if tracks[0].Year != 1977 {
|
||||
t.Errorf("Year = %d, want 1977", tracks[0].Year)
|
||||
}
|
||||
|
||||
// The release_year field, by contrast, tracks the specific release
|
||||
// owned (the 2013 reissue), so a 2010s filter on it MUST match.
|
||||
tracks, err = Evaluate(db, RuleSet{
|
||||
Rules: []Rule{
|
||||
{Field: "release_year", Operator: "between", Value: "2010,2019"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate release_year 2010s: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf(
|
||||
"release_year 2010s filter matched %d tracks, want 1",
|
||||
len(tracks),
|
||||
)
|
||||
}
|
||||
|
||||
// And a 1970s release_year filter must NOT match — the owned
|
||||
// release is from 2013.
|
||||
tracks, err = Evaluate(db, RuleSet{
|
||||
Rules: []Rule{
|
||||
{Field: "release_year", Operator: "between", Value: "1970,1979"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate release_year 1970s: %v", err)
|
||||
}
|
||||
|
||||
if len(tracks) != 0 {
|
||||
t.Errorf(
|
||||
"release_year 1970s filter matched %d tracks, want 0",
|
||||
len(tracks),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1945,7 +1945,7 @@ export class ConfigPage extends LitElement {
|
||||
return html`
|
||||
<config-section
|
||||
heading="Search Index"
|
||||
description="The explore search index pre-caches popular artists, albums, and tracks from ListenBrainz for fast offline search."
|
||||
description="The explore search index is built from the MusicBrainz/ListenBrainz data dumps — popular artists, albums, and tracks with listen counts — for fast offline search."
|
||||
.open=${true}
|
||||
>
|
||||
<div class="index-status">
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { DragPayload } from '@utils/drag-controller';
|
||||
import { ContextMenuController } from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import {
|
||||
createAlbumArtDragImage,
|
||||
createDragImage,
|
||||
@@ -264,7 +265,7 @@ export class CoverGrid
|
||||
private splitEntriesCacheKey: GridEntry[] | null = null;
|
||||
private splitEntriesCacheIndex = -1;
|
||||
|
||||
static override styles = coverGridStyles;
|
||||
static override styles = [coverGridStyles, exploreLinkStyles];
|
||||
|
||||
/* ====================================================================
|
||||
* Reactive state
|
||||
@@ -1832,7 +1833,7 @@ export class CoverGrid
|
||||
class="artist-name"
|
||||
title="${album.ArtistName}"
|
||||
>
|
||||
${album.ArtistName}
|
||||
${artistLink(album.ArtistName, album.ArtistMBID ?? '')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,9 @@ type MBTrack = explore.MBTrack;
|
||||
import { exploreCache } from '../../store/explore-cache';
|
||||
import { exploreSettings } from '../../store/explore-settings';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '../library-status-indicator/library-status-indicator.js';
|
||||
|
||||
@@ -113,6 +116,7 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
exploreLinkStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
@@ -451,6 +455,13 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
/* ── Lifecycle ── */
|
||||
|
||||
private unsubSettings?: () => void;
|
||||
private unsubReleasesReady?: () => void;
|
||||
/** Release-group MBIDs whose AlbumReleasesReady event we've handled,
|
||||
* so a background BrowseReleases fetch re-hydrates versions once. */
|
||||
private releasesReloaded = new Set<string>();
|
||||
/** Fallback timer that stops the versions spinner if the background
|
||||
* BrowseReleases fetch never signals readiness. */
|
||||
private releasesFallbackTimer?: number;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -465,11 +476,45 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
void this.loadAllData();
|
||||
}
|
||||
});
|
||||
|
||||
// A background BrowseReleases fetch (cold album, versions +
|
||||
// tracklist not cached yet) finished — re-fetch the versions once
|
||||
// per release group so they fill in without the initial request
|
||||
// having blocked on a live MusicBrainz browse.
|
||||
this.unsubReleasesReady = EventsOn(
|
||||
Events.AlbumReleasesReady,
|
||||
(mbid: string) => {
|
||||
if (mbid !== this.releaseGroupMBID) return;
|
||||
if (this.releasesReloaded.has(mbid)) return;
|
||||
|
||||
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
|
||||
this.releasesReloaded.add(mbid);
|
||||
void this.fetchReleases(mbid);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.unsubSettings?.();
|
||||
this.unsubReleasesReady?.();
|
||||
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm a one-shot fallback that stops the versions spinner if
|
||||
* AlbumReleasesReady never arrives (e.g. the background browse stalled
|
||||
* or the release group genuinely has no releases).
|
||||
*/
|
||||
private armReleasesFallback(mbid: string) {
|
||||
if (this.releasesFallbackTimer) clearTimeout(this.releasesFallbackTimer);
|
||||
|
||||
this.releasesFallbackTimer = window.setTimeout(() => {
|
||||
if (this.releasesReloaded.has(mbid)) return;
|
||||
|
||||
this.releasesReloaded.add(mbid);
|
||||
if (this.releases.length === 0) this.loadingReleases = false;
|
||||
}, 12000);
|
||||
}
|
||||
|
||||
/** Whether we've already scrolled to the highlight target. */
|
||||
@@ -595,7 +640,11 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
}
|
||||
|
||||
// Phase 2: fire API calls independently so each section
|
||||
// renders as its data arrives.
|
||||
// renders as its data arrives. Allow the versions section one
|
||||
// background-fetch re-fetch and arm a fallback so it can't spin
|
||||
// forever if AlbumReleasesReady never arrives.
|
||||
this.releasesReloaded.delete(mbid);
|
||||
this.armReleasesFallback(mbid);
|
||||
void this.fetchReleaseGroup(mbid);
|
||||
void this.fetchReleases(mbid);
|
||||
|
||||
@@ -828,13 +877,28 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
private async fetchReleases(mbid: string) {
|
||||
try {
|
||||
const releases = await BrowseReleases(mbid);
|
||||
this.releases = releases ?? [];
|
||||
this.buildClusters();
|
||||
|
||||
if (releases && releases.length > 0) {
|
||||
// Warm cache hit (or the background re-fetch landed):
|
||||
// authoritative MB versions replace any local placeholder.
|
||||
this.releases = releases;
|
||||
this.buildClusters();
|
||||
this.loadingReleases = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cold miss: BrowseReleases is cache-first + async and the
|
||||
// versions/tracklist are still being fetched in the background.
|
||||
// Don't clobber a tracklist already hydrated from the library —
|
||||
// keep showing it. Hold the spinner only when there's nothing
|
||||
// on screen yet; AlbumReleasesReady (or the fallback) resolves it.
|
||||
if (this.releases.length > 0 || this.releasesReloaded.has(mbid)) {
|
||||
this.loadingReleases = false;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.errorReleases = msg;
|
||||
console.error(`[explore-album] BrowseReleases error: ${msg}`);
|
||||
} finally {
|
||||
this.loadingReleases = false;
|
||||
}
|
||||
}
|
||||
@@ -1437,6 +1501,7 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
|
||||
const rg = this.releaseGroup;
|
||||
const artist = rg.artistCredit || '';
|
||||
const artistMbid = rg.artistMbid ?? '';
|
||||
const year = extractYear(rg.firstReleaseDate);
|
||||
const type = rg.primaryType || '';
|
||||
|
||||
@@ -1446,7 +1511,9 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
|
||||
return html`
|
||||
${artist
|
||||
? html`<div class="album-artist">${artist}</div>`
|
||||
? html`<div class="album-artist">
|
||||
${artistLink(artist, artistMbid)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${metaParts.length > 0
|
||||
? html`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user