mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
950e348357
64 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fadaa154e9
|
feat(library): sort and badge by personal difficulty rating (#810)
* feat(library): sort and badge by personal difficulty rating
Adds sort=difficulty/difficulty-desc to the library API (correlated
subquery over song_user_meta.user_difficulty, unrated songs pushed to
the bottom either direction, same pattern as the existing mastery
sort) and surfaces the rating as a badge on library cards in both the
v2 grid/tree views and the v3 grid. The rating itself already existed
(song_user_meta) — this just makes it sortable and visible, so it's
no longer only readable in the per-song edit drawer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(library): escape difficulty badge, wire tree view, add changelog+tests
- Wrap song.user_difficulty in esc() at both badge call sites
(static/app.js ~2082 and ~2283) for XSS-consistency with the
sibling tuning badge, which already uses esc().
- server.py: query_artists (the classic tree view's data source, used
by /api/library/artists) never batch-attached user_difficulty the
way query_page does for the grid, so the tree-view difficulty badge
added in
|
||
|
|
69c8ad4e0c
|
fix(settings): don't let a stale DLC path block saving the Demucs server address (#795)
Some checks are pending
ship-ci / ci (push) Waiting to run
The v3 Settings "Save" button posts dlc_dir together with demucs_server_url, default_arrangement and av_offset_ms in one request. POST /api/settings validated dlc_dir first and early-returned "DLC directory not found" before it ever processed demucs_server_url, so on a machine whose DLC path doesn't resolve (fresh install, unplugged/network drive, a path carried over from another machine) setting the Demucs server address silently failed — reported in got-feedBack/feedBack-demucs-server#3 (macOS 07-05 nightly). - server: a non-resolving dlc_dir is now recorded as a warning and skipped rather than aborting the whole POST, so the co-submitted keys still persist. The bad path is surfaced via a new additive `warnings` field and folded into `message` so the settings status line still shows it. - client (v3): the Demucs input now autosaves on blur/enter via a single-key persistSetting POST, like every other v3 setting, so it never depends on the coupled Save button. - tests: cover the decoupling and the unchanged happy path. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5be70939e4
|
feat(v3 library): searchable Cover Art Archive picker in Change-cover (#783)
The cover picker only offered CAA covers from a song's MATCHED release, so an
unmatched song (the city-pop pile) got nothing but Current/Pack/Upload/URL. Add
a search box: GET /api/song/{fn}/art/cover-search?q= searches MusicBrainz
release-groups and returns each album's CAA front-250 thumb; the picker renders
them as pickable tiles (same apply→/art/url path; covers with no CAA art
self-hide). Pre-filled from the song's artist + album/title (romaji fallback), so
a blank-artist pack pre-fills "Junko Yagami …". Reuses the throttled _mb_http_get.
|
||
|
|
6aaa2dcf47
|
feat(v3 library): batch→popup handoff + English-base romaji (metadata-curation capstone) (#782)
* feat(v3 library): click a "No match" badge to fix it — batch → popup handoff
Connects the two halves: the "No match" badge (the unmatched pile) now opens the
Fix-metadata popup for that song in one click, instead of right-click → menu.
The resting badge becomes interactive (pointer-events-auto + hover), carrying a
data-meta-fix hook; wireCards opens window.__fbFixMatch(playTarget) on click and
stops propagation so it doesn't also play the card. Batch tile states stay
non-interactive. Loop becomes: Unmatched filter → see the pile → click one →
fix it. tailwind.min.css regenerated for the badge's hover classes.
* feat(v3 library): show the author's romaji, not blank/native script (English base)
Two changes so an English-speaking base never sees a blank name or native script:
- Filename romaji fallback: a blank-artist CDLC pack ("Artist_Title_v1_p") shows
nothing useful (artist blank; title = the raw filename), and a match fills it
with kanji/kana. query_page + pack_fields now surface the author's own romaji
parsed from the filename ("Junko Yagami — BAY CITY") when the pack has no
artist of its own — display-only, keyset-safe (raw title stashed for the
cursor), a real pack artist or a user override still wins.
- Smart adopt: "Use these values" now KEEPS the readable romaji name + title the
card already shows and takes only album/year/genre (+ art via the pin) from the
match, so identifying a Japanese song gives "Junko Yagami — BAY CITY — FULL MOON"
with the right cover, never native script.
Tests: romaji fallback fires for a blank-artist CDLC pack (grid + pack_fields
agree) and is left alone when the pack has a real artist.
|
||
|
|
3100d68a45
|
feat(v3 library): genre field in the Fix-metadata popup Details tab (#780)
Adds Genre as a fifth Details field (edit / lock / revert / Yours-Pack provenance), backed by the existing override store. To make it actually useful, the genre FILTER and FACET now resolve the per-song override (effective genre = override else scanned pack genre) — guarded so the common no-override case stays on the plain indexed column — so a corrected/added genre is immediately browsable. Genre stays a library-only overlay: it is NOT a write-to-file field (split WRITE_FIELDS = the four file-safe fields from the five DETAIL_FIELDS), so Write to file leaves the genre override in place and the copy says so. The Match→Details bridge also carries a candidate's first genre. Tests: effective-genre facet + filter, and that a value-less lock doesn't invent an effective genre. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
af1170cec3
|
feat(v3 library): "Fix metadata" popup — per-song override + lock, cover picker, MusicBrainz + AcoustID (#777)
* feat(library): metadata override + lock store, enforced by enrichment (popup slices 1–2)
Backend foundation for the Fix-metadata popup. Not yet surfaced in the UI (the
display + 3-tab popup are the next slices); no PR until it's user-visible.
Slice 1 — the store:
- `song_field_override(filename, field, value, locked)` table: a reversible
DISPLAY overlay (never written to the pack), filename-keyed so it survives a
rescan (never purged by delete_missing) and is dropped only with the song.
- DB methods (partial upsert that drops empty+unlocked rows; batch map) +
`GET`/`PUT /api/song/{fn}/overrides` (field allowlist title/artist/album/
year/genre; clearing rides PUT since DELETE /api/song/{path} shadows sub-
routes; PUT demo-blocked).
Slice 2 — locks respected by enrichment:
- The auto-matcher composes a per-song `_compose_lock_filter` onto the global
apply-filter, so a match still applies IDENTITY (mbid/release → art) but never
re-canonicalizes a LOCKED display field.
- Gap-fill (write-to-file) skips locked album/year/genre — writing the matched
value would be exactly the clobber the lock exists to prevent.
- Review/manual picks bypass the filter (an explicit confirm overrides a lock).
Tests: store semantics + rescan-survival + API; the lock filter + reader; an
auto-match leaving a locked field un-canonicalized; gap-fill excluding locked
keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): show per-song overrides in the grid (popup slice 3)
The grid now displays the user's per-song title/artist/album/year override
in place of the pack value ("grid shows only overrides") — a matched
MusicBrainz canon never silently re-titles a card; canon stays in the
Details drawer + art. Overlaid in Python over the visible window, keyset-safe
like the P4 artist-alias re-label: the seek still runs on the raw column, and
the one overridable keyset column (title) stashes its raw value for the cursor
so paging never skips/dupes. The private stash is dropped from the payload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): 3-tab Fix-metadata popup — Details / Cover art / Match (slice 4)
Turns the thin single-song fix-match modal into the Plex-style metadata
editor reached from a card's "Fix metadata…" menu:
- Details tab: type + lock the displayed title/artist/album/year. Values
ride the reversible override store (GET/PUT /api/song/{fn}/overrides); each
field sits on its pack value (Yours/Pack provenance + revert-to-pack), a lock
pins it against auto-match, and Save repaints the grid via library:changed
(slice-3 overlay). This is the real tool for the blank-artist city-pop pile
MusicBrainz can't surface — you just type the right title.
- Cover art tab: hands off to the shared image picker (its own modal); the
pick refreshes the thumbnail everywhere.
- Match tab: the existing MusicBrainz search + candidate/pick flow, refactored
into shared body/footer helpers (the queue-review flow is untouched).
Backend: GET /overrides now also returns the pack baseline so the Details tab
can pre-fill + show provenance. tailwind.min.css regenerated (build-tailwind.sh)
for the popup's new utility classes.
Identify-by-audio (AcoustID) is deferred: it lives in unmerged PR #759, off
main — the Match tab gains the button once #759 lands and this branch rebases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): wire "Identify by audio" in the tabbed popup's Match tab
The AcoustID Identify button (#759) merged in referencing an out-of-scope
`panel` in the wiring — a leftover from the pre-popup fix-match modal that my
tab refactor renamed to `root`. Under strict mode that threw, so the handler
never attached and the button did nothing. Scope it to `root` (the tab body),
which is where the search-results area it renders into lives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): make "Identify by audio" outcomes unmistakable
An empty AcoustID result read the same as a broken button. Each state now says
plainly which outcome it is — ✓ fingerprinted-but-no-match vs no-audio vs off vs
unavailable — and, in the popup, points at the manual fallback (Search, or set
the album in Details + cover in Cover art by hand). A ✓ marks the states that
actually ran, so "worked, found nothing" no longer looks like a failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
3e036e3db6
|
feat(v3 library): persistent "no match" badge + Unmatched quick filter (#781)
* feat(v3 library): persistent "no match" badge + Unmatched quick filter The Refresh-Metadata batch (#764) shows a transient per-tile "no match" only while a pass runs, so the unmatched pile goes quiet at rest. Two additions make it visible + reachable: - Persistent per-card "No match" badge: query_page now marks each row `unmatched` (a cheap failed-set membership like favs/estd), and enrichBadge paints a subtle resting marker for those cards — tracked in a `_unmatched` set so a batch tile clearing falls back to it instead of wiping it. A live batch tile still wins while a pass runs. - "Unmatched" toolbar toggle (local-only): one click applies the same filter as the drawer's Match → Unmatched (match_state='failed'), so the no-match pile is a click away right after a batch. Re-queries + reflects active state. Test: query_page flags a failed row + the match=unmatched filter returns it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(v3 library): repaint persistent no-match badge after metadata tile-clear _clearMetaTiles removed every .v3-meta-tile node — including the new persistent 'No match' resting badge, which derives from _unmatched rather than _metaTile. A metadata rescan's tile-clear therefore dropped the badge until the next scroll re-rendered the card. Repaint it from _unmatched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
74cff4e0d6
|
feat(enrichment): alias-aware scoring — auto-confirm non-Latin-primary artists (#772)
Some checks are pending
ship-ci / ci (push) Waiting to run
* feat(enrichment): loose MusicBrainz search fallback (find aliased artists)
The MB text search used a strict field-phrase query
(`recording:"<title>" AND artist:"<artist>"`). A field phrase only matches
MusicBrainz's *primary* artist/title — it never searches ALIASES — so a
recording stored under a non-Latin primary name (大橋純子) whose romanized
form ("Junko Ohashi") is only an alias returns ZERO results, even though MB
has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop
charts) were unsearchable.
- `build_recording_query(..., loose=True)` drops the field scoping + phrases
for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`),
which searches the whole document incl. aliases.
- `_mb_search_recordings` runs the strict query first (unchanged, high
precision) and only on an EMPTY result retries once with the loose query —
so mainstream matches are untouched and the extra throttled request is spent
only on a miss. Results are re-scored by rank_candidates, so recall goes up
without lowering match quality (auto-accept still needs the per-field floors).
Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer"
(both 0 under the strict query) now surface the real records; "AC/DC /
Highway to Hell" still hits strict at score 1.0 with no loose retry.
Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just
appear as manual candidates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(enrichment): alias-aware scoring (auto-confirm non-Latin-primary artists)
Builds on the loose-search fallback: that surfaces a recording stored under a
Japanese primary name (大橋純子) via its romanized alias, but the SCORER still
compared the reference ("Junko Ohashi") against the primary only → artist
similarity 0 → below the auto floor, so it could only ever be a manual
candidate, never an auto-fill.
- mb_match: `cand_artist_sim` takes the best similarity over the candidate's
primary name AND its `artist_aliases`; score_candidate + classify use it.
- server: `_mb_artist_aliases(id)` fetches an artist's aliases (one throttled
lookup, process-cached — a one-artist discography costs ONE request) and
`_alias_enrich` attaches them ONLY to promising near-misses (title agrees,
primary artist doesn't) so a normal pass spends zero extra requests. Wired
into both the auto-matcher (_enrich_one) and the manual search proxy.
Verified live: "Junko Ohashi / Telephone Number" → 大橋純子 candidate goes from
score 0.5 (loose-only) to 1.0 (auto-confirmable), ranked #1; "AC/DC / Highway
to Hell" unchanged at 1.0 with no alias lookup.
Stacks on #771 (feat/mb-loose-search-fallback).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(enrichment): keep live exclusion in the loose search fallback
The loose fallback dropped the strict path's -secondarytype:Live filter, so a
studio chart whose strict query missed could fall back to — and, since
score_candidate doesn't penalize live takes, auto-confirm — a live-only
recording. Apply the same live gate to the loose query (skipped only when the
source title is itself a live take).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
18c4e229e1
|
feat(enrichment): loose MusicBrainz search fallback (find aliased/romanized artists) (#771)
* feat(enrichment): loose MusicBrainz search fallback (find aliased artists)
The MB text search used a strict field-phrase query
(`recording:"<title>" AND artist:"<artist>"`). A field phrase only matches
MusicBrainz's *primary* artist/title — it never searches ALIASES — so a
recording stored under a non-Latin primary name (大橋純子) whose romanized
form ("Junko Ohashi") is only an alias returns ZERO results, even though MB
has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop
charts) were unsearchable.
- `build_recording_query(..., loose=True)` drops the field scoping + phrases
for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`),
which searches the whole document incl. aliases.
- `_mb_search_recordings` runs the strict query first (unchanged, high
precision) and only on an EMPTY result retries once with the loose query —
so mainstream matches are untouched and the extra throttled request is spent
only on a miss. Results are re-scored by rank_candidates, so recall goes up
without lowering match quality (auto-accept still needs the per-field floors).
Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer"
(both 0 under the strict query) now surface the real records; "AC/DC /
Highway to Hell" still hits strict at score 1.0 with no loose retry.
Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just
appear as manual candidates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(enrichment): keep live exclusion in the loose search fallback
The loose fallback dropped the strict path's -secondarytype:Live filter, so a
studio chart whose strict query missed could fall back to — and, since
score_candidate doesn't penalize live takes, auto-confirm — a live-only
recording. Apply the same live gate to the loose query (skipped only when the
source title is itself a live take).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
fa2d12222a
|
feat(v3 library): "Refresh Metadata" button + per-view re-match + filename-artist seed (#764)
Adds a media-server-style "Refresh Metadata" control to the Songs toolbar
(beside "⟳ Refresh") — the metadata counterpart to a file scan.
- Re-matches the songs currently SHOWN (the visible grid window) against
MusicBrainz: a per-view refresh that's visible even on an already-matched
library. The button doubles as Stop while a pass runs; a batch progress bar
+ per-tile queued→working→done badges show what's happening. User-pinned
`manual` matches are never re-matched.
- Backend: POST /api/enrichment/{cancel,states,rematch}; /status gains
total/matched/current/cancelling; a cooperative cancel Event is checked
between songs in the match + art phases so Stop halts without waiting for
the whole queue. `states` is read-only (open); `cancel`/`rematch` are
demo-blocked.
- Matcher: when a pack's `artist` field is blank (common in community
charts), derive artist/title from the CDLC `Artist_Song-Title` filename
convention as a SEARCH SEED so text matching can identify it — the displayed
values still come only from the confirmed MusicBrainz match, nothing
estimated is shown as author-set. Rescues blank-artist packs that otherwise
always failed.
Tests: enrichment_states_for, the three new routes, cancel-halts-a-pass,
kick-clears-stale-cancel, filename parse, blank-artist seeding, and
present-artist-not-overridden.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
73c5ab149e
|
feat(enrichment): AcoustID audio-fingerprint identification (opt-in) (#759)
* feat(enrichment): AcoustID audio-fingerprint identification (opt-in)
Text search can only guess the version; the definitive fix is content-based —
fingerprint the actual audio with Chromaprint (fpcalc) and look it up on
AcoustID, which maps the fingerprint to the EXACT MusicBrainz recording (the
approach Lidarr uses). Sidesteps the studio-vs-live ambiguity entirely.
- lib/acoustid_match.py: pure response parsing + config gating (unit-tested);
normalizes AcoustID hits into the same candidate shape as mb_match so the
review UI + editor Match popup render fingerprint and text hits identically.
- server.py: _fpcalc (Chromaprint subprocess), _acoustid_lookup (throttled,
offline-guarded HTTP), _identify_by_fingerprint (also available to the
library-enrichment pipeline), and POST /api/enrichment/identify (upload the
master audio → candidates).
- Fully OPT-IN and graceful: absent the fpcalc binary or an ACOUSTID_API_KEY
the whole path is a no-op / 503 and the text matcher runs unchanged.
Requires (both optional): the `fpcalc` (Chromaprint) binary on PATH/$FPCALC,
and a free AcoustID application key in $ACOUSTID_API_KEY. Pure parsing/gating
is unit-tested; the fpcalc + live-lookup path needs those two to exercise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(enrichment): make AcoustID self-serve — opt-in toggle + API key in settings
Fingerprinting was env-var only (ACOUSTID_API_KEY), so only an operator could
enable it. Add two core settings so a user can turn it on themselves:
- acoustid_enabled (bool, default OFF — opt-in)
- acoustid_api_key (string, ≤128 chars, trimmed; env var stays a fallback)
_acoustid_available()/_acoustid_lookup() now resolve (enabled, key) from
settings via _acoustid_settings(). /api/enrichment/identify distinguishes
"not set up" (412 needs_setup — the UI nudges the user to enable it) from
"set up but fpcalc/network missing" (503) so the client never fakes a match.
Verified: default off; POST round-trips + trims; 412 vs 503 gating; bad
types/over-length rejected. acoustid_match unit tests green (8/8).
* fix(enrichment): POST the AcoustID lookup instead of GET
A Chromaprint fingerprint is multi-KB (a 3.5-min track ≈ 3.5k chars), so
sending it as a GET query param overflows the request URL for longer songs and
fails spuriously. AcoustID accepts the same params form-encoded — POST them.
* fix(acoustid): space-separate the lookup `meta` (was silently dropping metadata)
The meta value was `+`-joined ("recordings+releasegroups+compress"). Sent over
the wire the literal `+` percent-encodes to %2B, which AcoustID does NOT split
into flags — so every hit came back with an empty `recordings` array and the
parser produced zero candidates (a fingerprint match that resolved to nothing).
AcoustID wants the flags space-separated. Verified against real fingerprints:
`+`-joined → 0 recordings; space-joined → 28, resolving Highway to Hell and
Living After Midnight to their canonical studio albums as the top hit.
* feat(acoustid): resolve the canonical original album + year from the fingerprint
AcoustID hits resolved the right recording but a weak album/blank year: the
album picker took the first studio-typed group (a later comp/soundtrack typed
"Album" could win) and the year took an arbitrary release (often a reissue).
Request the `releases` meta (which carries per-release dates) and use them to
(1) pick the EARLIEST original studio album among the groups and (2) fill the
year from that album's earliest release. Verified against real fingerprints:
Smoke on the Water → Machine Head (1972) not a later comp; Highway to Hell →
1979; Living After Midnight → British Steel (1980). +2 unit tests.
* feat(acoustid): per-song "Identify by audio" for the library metadata tooling
Add POST /api/enrichment/identify/{filename} — fingerprints an EXISTING library
song's own master audio (resolves the sloppak's original_audio or a loose
folder's audio), the library counterpart to the upload-based /identify used by
the editor. Wire an "Identify by audio" action into the match-review / Fix-match
modal: it renders fingerprint hits in the same candidate list and pins the pick
via the existing /review/{f}/pick. Shared _acoustid_gate() (412 needs_setup /
503) for both endpoints; 404 when a pack has no full mix. Both identify routes
added to the demo-mode block list (they spend fpcalc + the AcoustID budget) —
fixes a pre-existing miss on the upload route.
* fix(acoustid): regenerate stale tailwind CSS + cap identify upload
- static/tailwind.min.css was stale vs a fresh rebuild (ci/tailwind-fresh red);
regenerated with the pinned tailwindcss@3.4.19 (byte-stable).
- /api/enrichment/identify read the whole multipart upload into memory before
writing it; stream it to the temp file with a 256 MB cap (413 over) so an
oversized upload can't balloon RAM. fpcalc reads from the temp file anyway.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(acoustid): pre-parse upload guard + settings UI to enable it
- /api/enrichment/identify is now async: a pre-parse Content-Length check +
request.form(max_part_size=…) reject an oversized body BEFORE Starlette spools
the multipart to temp disk (mirrors the song-upload endpoint), and the blocking
fpcalc subprocess + AcoustID HTTP run off the event loop via run_in_executor.
- The v3 Metadata-matching settings card gains an 'Identify by audio' opt-in
toggle (acoustid_enabled, default OFF) + an AcoustID key input
(acoustid_api_key), wired in match-review.js — so the advertised feature is
reachable from the UI instead of only via a manual settings POST. Reuses
existing classes only; committed tailwind.min.css stays fresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
a65d8cfa13
|
fix(enrichment): rank the canonical studio take over live/comp versions (#758)
* fix(enrichment): rank the canonical studio take over live/comp versions A flat MusicBrainz /recording text search ties every take of a song at the same score, so "AC/DC — Highway to Hell" returns a wall of live bootlegs and compilations with the 1979 studio version buried (or below the fetch limit). - build_recording_query: drop live-ONLY recordings (`-secondarytype:Live`). Compilations are deliberately kept — they REUSE the studio recording, so filtering them cuts the very recording we want (verified against MB). - _best_release / parse_recording_doc: pick the canonical studio album (primary Album, no Live/Compilation/Remix/... secondary type) for the displayed album/year, and expose a `studio` flag. - rank_candidates: since the combined score caps at 1.0 (perfect text match ties), break ties on the studio flag and — when the caller knows the audio length — on duration proximity, so the studio take wins over live/extended cuts. The studio distinction is intentionally NOT scored (a live take is still the right SONG), only re-ordered. - /api/enrichment/search: accept an optional `duration` param so a caller that has the audio but no library row (the editor's create modal) can pass the master-track length for the duration tiebreak. Verified end-to-end against live MusicBrainz: AC/DC "Highway to Hell" now returns the 1979 studio recording at #1 with the correct album + year. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enrichment): official releases outrank unofficial studio albums _best_release sorted (clean, status_ok, date), so an UNofficial bootleg Album outranked an official Single/EP/comp — regressing canonical album/year and seeding cover-art from a bootleg for single-only songs. Order status_ok before clean: official first, then prefer a clean studio album among the official releases (still surfaces the studio album over an official live/comp album). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enrichment): keep live recordings for genuinely-live charts build_recording_query unconditionally added -secondarytype:Live, but denoise() strips a '(Live at …)' qualifier from the query — so a chart that IS a live take had its only correct recording filtered out (both background enrichment and manual search). Skip the live filter when the source title carries a parenthetical live marker; a bare title word ('Live and Let Die') still filters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enrichment): drop the studio tiebreak when the chart is a live take Follow-through on keeping live recordings for live charts: rank_candidates still ranked the studio take ahead of a tied live one, so a live chart would auto-match the studio recording. Skip the studio tiebreak when the source title has a live marker — duration proximity + score then pick the right live version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a86abadb14
|
settings: add host instrument profiles (#753)
* settings: add host instrument profiles Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * settings: add instrument pathway selection Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5 Five regressions from the instrument-profiles rework: 1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST froze default profiles into config.json (broke test_empty_post_preserves_all_existing_keys). Gate on the save touching instrument settings; GET already virtualizes profiles. 2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a no-op. reset_settings now resets pathway inside the persisted profiles too. 3. Per-profile tuning validation rejected provider/custom tunings (tuner plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to every built-in table while still rejecting a built-in misapplied to the wrong key. 4. First-migration overwrote an explicit active_instrument_profile with the legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use setdefault so an explicit request wins. 5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a 4-string tuning). Updated to the valid 'Drop A'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch Two partial-update follow-ups: - save_settings normalized a POSTed instrument_profiles by FILLING every omitted profile with defaults and replacing wholesale, so a one-profile update reset the others. Validate each PROVIDED profile individually and merge the partial over the persisted set inside the lock — /api/settings is partial-merge. - the string-count picker posted only string_count, so the backend silently reset a now-invalid tuning to Standard while the UI kept the old value (settings/tuner desync). Clamp + post the valid tuning too, mirroring the instrument-switch path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
41e907fa52
|
fix(library): serve art/load for songs mounted through a library junction (#766)
* fix(library): serve art/load songs mounted through a library junction A song library mounted through a directory JUNCTION/symlink subfolder (a library shared across app installs; the desktop app's own mounts) had broken album art and couldn't load: the scanner's rglob follows the junction and indexes the songs, but _resolve_dlc_path (via safe_join's .resolve()) followed the junction to its real target, saw it outside DLC_DIR, and rejected every song reached through it → 403 on /art, 404 on /art/candidates, broken covers. - _resolve_dlc_path now uses LEXICAL containment (os.path.normpath, no symlink following) so an in-library junction is allowed, while `..` traversal and absolute paths are still rejected (the traversal tests pin this). - safe_join is left STRICT (.resolve()-based) — it is the zip-slip / plugin- asset / avatar guard, where following a symlink out IS the defense — but gains an explicit NUL guard (on Python 3.13/Windows resolve() no longer raises on an embedded NUL, so the byte was leaking through; strictly-more- rejection, no effect on the zip-slip contract). Tests: test_dlc_junction (junction allowed; `..`/absolute/NUL rejected; the safe_join-stays-strict contrast). Existing traversal/safepath/art-candidates suites stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): reject Windows drive-letter paths in _resolve_dlc_path The new test_absolute_path_rejected pins 'C:/Windows/system32/x' → None, but on POSIX a drive-letter path isn't absolute, so Path(dlc)/'C:/…' becomes the contained relative dir '<dlc>/C:/…' and slipped through the lexical containment check (red on the Linux CI). Not an escape, but the traversal contract should hold cross-platform (a shared library is reached from either OS). Reject a path that is absolute or drive-qualified in either POSIX or Windows semantics before the containment check. Legitimate relative/junction paths are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2c1c6f7eac
|
fix(starter): sync _BUILTIN_STARTER_SOURCES with content/starter on disk (#775)
Two commits (delete beethoven-ode_to_joy, re-add The Adicts' Ode to Joy) never updated _BUILTIN_STARTER_SOURCES: it still listed the deleted pack and omitted the added one. The listed-but-missing file made the all-present gate never fire, so NO starter content seeded on first run — and the on-disk-but-unlisted pack would bundle as dead weight. Both starter-seed guard tests were red on main, reddening ci/test on every core PR. Sync the manifest to disk. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
14eaad09e9
|
feat(library): add Star-Spangled Banner + Ode to Joy starter content (#744)
Add two more public-domain starter songs alongside Für Elise, wired into _BUILTIN_STARTER_SOURCES so they seed into DLC_DIR/starter/ on first run: - The Star-Spangled Banner (lead) — John Stafford Smith; cleaned the "Unknown" artist / placeholder album, author "Fee[dB]ack". - Ode to Joy (lead/rhythm/bass + drums) — Beethoven. Replaces the raw "Ode to Joy (VST Cover)_The Adicts.feedpak" that was committed to main but never added to the seed list (so it bundled 23 MB of dead weight and never appeared). Fixed metadata (artist Beethoven, year 1824, author "Fee[dB]ack"), and repointed the stem from the 22 MB editor WAV to the byte-identical-render full.ogg (both exactly 85.324 s), shrinking the pack 23.8 MB -> 1.7 MB. Add guard tests asserting every _BUILTIN_STARTER_SOURCES entry has its file committed and that a seed run lands them all — this catches exactly the listed-but-missing (or committed-but-unlisted) mismatch that left Ode to Joy un-seeded. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7c873f5cc2
|
feat(library): seed bundled starter content into the library on first run (#743)
Ship a public-domain Für Elise (keys) feedpak as starter content so a fresh install isn't an empty library. server._seed_builtin_starter_content() copies bundled packs into DLC_DIR/starter/ exactly once, guarded by a marker in CONFIG_DIR — unlike the always-reseeding diagnostic seed, a user who deletes the starter song does not get it back. `starter/` is deliberately outside the diagnostics/tutorials library carve-out so the song surfaces as a normal library entry. Extract the shared symlink-safe, mtime-aware copy loop into _copy_builtin_packs() and route both the diagnostic and starter seeds through it (diagnostic behavior unchanged; existing tests green). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
97a941c45d
|
fix(tests): join background scan/enrich workers before closing the DB (#735)
Root cause of the flaky pytest segfault (exit 139): the background scan and enrichment daemon threads (_scan_runner/_enrich_runner) use the shared MetadataDB connection, but test fixtures closed that connection in teardown without stopping them. A daemon thread mid-query on a freed SQLite conn is a native use-after-free → SIGSEGV. The app's startup kicks a scan, so almost any app-booting fixture was vulnerable. It only surfaced now because got-feedback/feedBack#728 added a push trigger, so ci/test runs on every push to main. Fix: server.py retains the scan/enrich thread handles and adds _join_background_db_threads(); every test fixture now joins the workers before conn.close(). Verified: the full suite runs to completion (no segfault) where it previously crashed at ~25%. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
be9e965001
|
v3 library: artist pages — in-your-library view, similar-in-library, links-only web links (#731)
* v3 library: artist pages — in-your-library view, similar-in-library, links-only web
The greenlit artist-pages feature. Every page renders from LOCAL data;
an optional, opt-in external-links strip is the only network surface.
Server:
- artist_enrichment table (mb_artist_id PK, url_rels JSON, genres JSON,
fetched_at) — never purged; one row per matched MusicBrainz artist.
- GET /api/artist/{name}/page — all-local: canonical name (+ raw alias
variants), song/album/mastered counts, album list, similar-in-library
(top artists by shared genre, self excluded, empty is fine), and the
artist's MB id when any matched/manual song carries one. THE DENOMINATOR
LAW: "N mastered" counts songs you OWN (best_accuracy >= 0.9 across the
artist's library songs), never a global discography — a stored score for
a song no longer in the library does not count.
- GET /api/artist/{name}/links — lazy, cached-forever: returns the cached
row, else (external links enabled + network + a known MB artist id) ONE
throttled artist lookup (inc=url-rels+genres+tags), whitelisted into
{official, tour, video, social[], wikipedia}. Every URL passes the same
http(s) scheme gate as art redirects, so a hostile javascript:/data:/file:
can never reach an href. POST .../links/refresh re-fetches. Offline /
no-mbid / links-disabled → empty, no error. Both routes demo-blocked.
- Settings keys artist_pages_enabled (default ON — local-only) and
artist_external_links (default OFF — opt-in per the dev-chat thread).
Frontend (static/v3/songs.js): an in-place sub-render mirroring openAlbum()
with a "← Song Library" back + scroll restore. 2x2 album-art mosaic header
(borrows the playlist-cover renderer), canonical name + "also shown as"
variants + a Matched·MusicBrainz pill when known; stats strip that omits
the mastered segment at zero (invitational, never "0 mastered"); Play all /
Shuffle (playQueue) + Save as smart playlist (collections rule {artist});
album rail → openAlbum; song list via the artist filter + wireCards;
"Similar in your library" chips → open that artist; and the external-links
row under an "On the web · opens your browser" divider, each link
target=_blank rel=noopener noreferrer with its domain shown — rendered only
when external links are on AND links exist. Empty modules hide.
Entry points: card ⋮ "Go to artist", the grid card artist line, and a
"View artist page" link in the Details drawer — all via
window.__fbOpenArtistPage.
Tests: tests/test_artist_page.py — page counts/albums/alias folding, the
denominator law (owned-only, best-across-arrangements), similar ranking +
empty, mb-id only from matched rows, links whitelist + scheme gate (a
javascript: and an ftp:// URL both rejected), disabled-by-default no
network, cache-no-second-fetch, refresh, demo block. 21 pass (35 with
artist_alias). node --check clean; tailwind rebuilt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3 artist pages): unfiltered album view + select-mode row guard on artist page
Artist-page album click no longer applies the global library filters: openAlbum
gains an ignoreFilters option that builds the /api/library request scoped only to
artist+album (no drawer/genre/tuning/search params), so the album view and
Play-album match the artist page's full-shelf counts. The normal albums-view
click path is unchanged (ignoreFilters defaults off).
Select-mode row clicks on the artist page now toggle selection instead of playing.
Extracted the grid/tree capture-phase select guard into a shared bindSelectGuard()
and attach it to the persistent artist-page host too. Each host is bound once at
shell build; innerHTML re-renders reuse the same element, so there is no
double-binding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
64a499975e
|
v3 library: multi-candidate cover picker — select from a populated list (#732)
* v3 library: multi-candidate cover picker — select from list, media-server style
Auto-best already ships; this adds the "pick from a populated list" surface
Christian asked for, as ONE reusable component (song covers now; album/artist
art reuse the same picker later).
Server:
- GET /api/song/{filename}/art/candidates — assembles WITHOUT hoarding: the
Current image + its provenance, Pack art when present, and Cover Art Archive
candidates for the matched release (and any release ids stored on a review
row's candidates). New _caa_release_index() fetches the CAA release INDEX
json (image list + types + thumb sizes) through the existing throttle +
offline gate, cached as caa_index_{id}.json beside the covers (indexes are
stable). Capped at 12; fetched on demand. Demo-blocked (it spends the rate
budget) and offline → instant tiles only, no error.
Frontend: new static/v3/image-picker.js — window.__fbOpenImagePicker({filename,
title}), a body-appended singleton modal (match-review anatomy: overlay, focus
trap, Esc). Current image + provenance badge on the left; a tile grid on the
right whose instant tiles — Current, Pack original, Upload, Paste URL — work
immediately even offline, while CAA candidates load behind ONE /art/candidates
fetch with skeleton tiles + a "the source is rate-limited" caption. The fetch
is tied to an AbortController and cancelled when the modal closes.
Applying a pick reuses EXISTING routes so there's no new write path and the
design's key trick holds: a chosen cover POSTs to …/art/url (the override
lane — never evicted by the art-cache LRU, survives a re-match); "Pack
original" DELETEs the override; Upload POSTs …/art/upload (GIF stays
upload-only + local-only). Silent-on-success; the drawer/card art refreshes
via the existing cache-buster.
Entry points: the Details drawer art click (the old direct file dialog is now
the Upload tile) and a card ⋮ "Change cover…" action.
Tests: tests/test_art_candidates.py (matched row lists index images; review
row pulls in candidate releases; unmatched/offline → instant tiles only;
index cached, no second fetch; demo blocked) over a fake index seam.
30 pass with test_art_layer green (same seams). node --check clean; tailwind
rebuilt for the new file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3 cover picker): uiPrompt over window.prompt, visible-only focus trap, gate CAA to matched rows, index-cache lock, abort-on-reopen; +traversal tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
8c7cde5d5c
|
v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)
* v3 library: first-hour polish — zero-states, match progress, provenance, alias search
Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.
- Invitational repertoire meter: with no practice data yet, the home meter
no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
empties — attempts exist but all mastered (honest empty shelf) vs nothing
attempted yet (day one) → new starter_suggestions() returns up to 8
approachable songs (90–480s, shortest first) flagged starter:true, and the
client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
"Matching your library — X of Y" line sits by the review chip (5s poll,
single guarded interval, cleared the moment the pass stops — no leak,
no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
Archive), that results are stored locally, that files aren't changed
without you, and where the switch is. localStorage-gated, wrapped so a
blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
query/filter) shows "Your library is empty" + drop-files hint + Open
Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
table. Probe-guarded so a no-aliases library keeps the exact original
3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
<artist — title> (source) · Fix match" under the Identity fields — the
wrong-match escape hatch at the point of the data, wired to the same
fix-match flow the card menu uses. New read-only GET
/api/enrichment/song/{filename} backs it.
Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3 library): stop match-progress poll when leaving the library screen
The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
f5c9c34291
|
library: scraper options — per-source + per-field auto-apply, review-queue order (R1) (#726)
* library: scraper options — per-source + per-field auto-apply toggles, review-queue order (R1) Grows the Settings→Library "Metadata matching" card into the full scraper-options panel — not everybody needs the same things out of a scraper: - Sources: enrich_src_musicbrainz gates the background matcher (phase 2; identity hashes still stamp, and manual Fix-match/search stays available — same contract as the master toggle). enrich_src_caa gates the Cover Art Archive fetch (phase 3). Rows skipped by an off toggle stay unevaluated, so re-enabling picks them up on the next pass — nothing is permanently forfeited. - Auto-apply fields: enrich_apply_names/year/genres filter what an AUTOMATIC match may canonicalize (_enrich_field_filter, applied on all three automatic paths: cache copy, mbid/isrc exact keys, text auto). MusicBrainz ids always stamp — they're identity, not display; the art fetch and future re-matching need them. A match the user confirms in the review modal applies in full. enrich_apply_art gates the art fetch alongside the CAA source toggle (two axes, one behaviour today — future art sources slot in without re-teaching the panel). - Review queue order: enrich_review_order = missing_first (default, today's behaviour) | artist | recent, read by GET /api/enrichment/review; unknown stored values degrade to the default. - Settings card: Sources / Auto-apply / Review-queue-order groups wired in match-review.js; the master toggle is relabelled "Match songs automatically" so it doesn't read the same as the new MusicBrainz source toggle. No tailwind rebuild needed — every class was already scanned from core source. Tests: tests/test_scraper_options.py (9) — settings validation, MB-source-off stamps-without-matching + re-enable, per-field stripping on auto matches with ids preserved, review-accept full-apply despite toggles, CAA gating on both axes, review-order modes incl. the unknown-value fallback. Full-suite failure set A/B-identical to the base (39 env/pre-existing). Stacked on feat/enrichment-art (#715) — merge that first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * library: per-field auto-apply honours "nothing forfeited" (backfill + no partial seeding) The R1 per-FIELD auto-apply toggles settled a `matched` row with the disabled fields stripped, but enrichment_pending() never revisits an unchanged-hash matched row — so re-enabling a field never backfilled it, and enrichment_cache_lookup() (which gates only on mb_recording_id) could seed sibling charts with the stripped blanks. This broke the same "nothing is permanently forfeited" contract the source (mb_on) and art (art_on) toggles already keep. Fix: persist an `apply_mask` marker (sorted blocked apply-keys) on every AUTOMATIC match: - migration: additive `apply_mask TEXT` column (idempotent ALTER). - enrichment_pending(allowed_keys=...): re-queues a `matched` row whose apply_mask names a field that is now re-enabled → backfill on re-enable, converges (a fully-applied row is never re-queued). - enrichment_cache_lookup: only fully-applied donors (apply_mask empty/NULL) may seed siblings; a partial row is skipped and the sibling falls through to its own re-filtered match. - _enrich_apply_mask()/_enrich_blocked_apply_keys() helpers; threaded through _enrich_one → apply_enrichment_match. Review/manual writers leave it NULL (a confirmed pick applies in full). Tests: re-enable-backfills-and-converges; partial row is not a cache donor (fully-applied one is). 13 scraper-options tests pass; 170 enrichment/ settings tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7ca736d525
|
library: cover-art layer — CAA auto-fetch + user overrides, GIF local-only (P9) (#715)
* library: cover-art layer — CAA auto-fetch + user overrides, GIF local-only (R3/P9)
Third slice of the enrichment series (stacked on the matcher): covers.
- Serve chain for GET /api/song/{fn}/art: USER OVERRIDE -> PACK ART ->
COVER ART ARCHIVE cache -> 404. Behaviour change, deliberate: a
user-uploaded cover now OVERRIDES pack art (previously the upload only
filled the no-art gap, which made custom art look broken on any song
that already shipped a cover).
- GIF is allowed as an override and kept VERBATIM (animation intact) —
a local-only bonus. Everything else normalizes to RGB PNG as before.
One override per song (saving either kind removes the other), and
nothing ever writes art INTO a pack file — test-pinned: the pack's
cover.jpg is byte-identical after a GIF upload.
- Art by URL: POST /api/song/{fn}/art/url fetches server-side (http(s)
only, 10 MB cap enforced while streaming) into the same override slot.
DELETE /api/art/{fn}/override drops it — under /api/art because the
greedy DELETE /api/song/{path} catch-all shadows anything beneath it
(the same dodge the chart split/unsplit routes use).
- Cover Art Archive fetch as phase 3 of the enrichment pass: matched
songs that LACK pack art get their release's front cover, throttled +
identified + offline-guarded exactly like the MusicBrainz client
(pytest can never reach the network; a transport error pauses the
pass without burning the row). The cache is keyed by RELEASE MBID —
ten charts of one album cost one fetch — and every outcome writes an
art_state (pack/user/caa/none/error) so a row is evaluated once.
- LRU cap (200 MB) on the CAA side of the cache only; user overrides
are never evicted, and evicted rows reset so a later pass may
re-fetch. Deleting a song removes its override files (CAA files stay
— they may be shared by other charts of the release).
No frontend changes: the grid, the review modal, and the player pick
the new art up through the same route they already use. The
upload/paste-a-link surfaces in the Details drawer land with the
context-menu slice once the drawer PR merges.
13 new tests (tests/test_art_layer.py) + demo-mode routes; full-suite
failure set byte-identical with the change stashed vs applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* library: harden cover-art layer — SSRF guard, demo/size caps, override-delete state reset
Follow-up hardening on the R3 cover-art layer:
- remove_song_art_override: reset the enrichment row (set_enrichment_art(fn,
None, None)) when an override is deleted, so a row previously settled as
'user' re-queues and the CAA fallback resumes. Previously a removed override
stranded the row (enrichment_art_pending only re-queues art_state IS NULL),
leaving the song with no art at all.
- Base64 art upload: block it in demo mode (was open — a write/disk-fill vector,
worse now that GIFs are stored verbatim), validate the filename resolves to a
real song (mirrors the url route), and cap the decoded payload at 10 MB.
- Art-by-URL: reject hosts that resolve to loopback/private/link-local/reserved/
multicast/unspecified addresses (SSRF, e.g. cloud metadata) and stop following
redirects (allow_redirects=False) so a redirect can't smuggle the request to an
internal target. Fails closed on unresolvable/unparseable hosts.
- _caa_http_get: stream with a per-file 10 MB cap (bounds any one response
independently of the aggregate LRU); guard release_id against a conservative
token before interpolating it into a cache-file path (no separators/dots).
Tests: delete-override→CAA-fallback, upload unknown-song/oversize rejection,
SSRF internal-host guard, and a demo-mode block assertion for art/upload.
Note: art_state='error' rows are intentionally not auto-retried — there is no
per-row attempt counter on the art side, so an unbounded retry could storm CAA
for permanently-bad rows; a bounded retry would need extra state, left out here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
8e953e8bc4
|
library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a) (#724)
* library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a)
The write-back contract agreed with the spec chair (alignment doc §7),
made executable, now that feedpak-spec 1.14.0 (mbid/isrc) is merged:
opt-in + user-initiated, adds ABSENT keys only, spec'd-keys allowlist,
values only from a CONFIRMED identity, atomic write + .bak. Single-song
only — batch write-back stays an open question with the chair.
- songmeta.gap_fill_sloppak: append-only manifest writer. Every added
key is absent by definition, so the new lines are APPENDED — the
author's existing bytes (key order, comments, formatting) survive
verbatim, unlike the metadata editor's full re-serialize. Directory
form gets a one-time manifest.yaml.bak + temp + atomic replace; zip
form reuses the editor's backup/temp/replace rewriter. Raises on any
already-present key: the never-clobber rule lives in the writer, not
just the callers.
- GET /api/song/{fn}/gap-fill: read-only preview — which of
album/year/genres/mbid/isrc are missing from the file (absent or
empty; year 0 = empty), with the values the enrichment match
supplies. Only a CONFIRMED identity is eligible (matched or a user
pin); review-tier rows are refused until a human confirms —
wrong-match > fast, same as everywhere else in the enrichment layer.
- POST /api/song/{fn}/gap-fill {keys}: writes the user-confirmed
subset. Proposals are RECOMPUTED under _song_io_lock, so a key that
gained an author value between preview and confirm is skipped, never
replaced. mbid/isrc written in canonical form only (validated).
DB stays scanner-consistent (album/year/genre columns + mtime/size
re-stat, cache invalidation + scan kick — the metadata editor's
contract). Demo mode blocks the write.
- Details drawer (Identity section): "Write missing info to file…" →
per-key checkbox confirm ("Only adds what's missing — nothing already
in the file is changed. A backup (.bak) is kept.") → written
confirmation; not-eligible states explain themselves. v3 only; no
new tailwind classes.
- Rides along: _manifest_exact_ids now strips ISRC display separators
(spec 1.14.0's strip rule) — a hand-authored "AU-AP0-90-00045" hits
the exact-match tier instead of silently falling back to text.
Tests: tests/test_gap_fill.py (10) — preview eligibility incl.
review-refusal + empty-as-gap, author-bytes-preserved-verbatim on dir
AND zip (with .bak content pinned), skip-not-replace on the mixed
request, the writer's ValueError guard, key validation, demo block,
DB sync; +1 hyphenated-ISRC test in test_mb_enrichment.py. 46 targeted
green; full-suite failure set A/B-identical to the main base (39
env/pre-existing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* gap-fill: align preview with append-only writer (no cleared-value 500)
The R4a preview offered present-but-empty manifest values (album: '',
genres: [], year: 0) as gaps, but the append-only writer's never-clobber
guard raises on ANY key already present — so a user-confirmed POST for
those keys turned into a 500 "write failed" instead of filling the gap.
Appending can't fill an empty-but-present key anyway (it would duplicate
the YAML key).
Fix: _gap_fill_manifest_absent now treats only genuinely-MISSING keys as
gaps; a present-but-empty value is left to the metadata editor (which
re-serializes and can replace in place). This closes the preview→POST
mismatch — the preview never offers what the writer would refuse.
Tests: test_preview_treats_empty_values_as_gaps replaced by
test_preview_excludes_present_but_empty_keys (present-but-empty not
offered; genuinely-absent still offered) + test_write_present_but_empty_
key_is_refused_not_500 (POST → clean 409, file untouched, no .bak; a
genuinely-absent key alongside still writes). Closes the write-path blind
spot in the original empty-value test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
55060c4f67
|
v3 library: artist sort orders titles within an artist (tree-view feel) (#720)
* v3 library: artist sort orders titles within an artist (tree-view feel) Tester report: "the list is set up by artist, but the cards are alphabetical(-ish random)". Real: the tree orders artist -> album -> title, while the grid's artist sort ordered within an artist by RAW FILENAME — community-pack filename noise, so an artist's cards looked shuffled. - artist / artist-desc gain a title secondary (direction baked per entry so the legacy `dir=desc` append can't land on the title term; titles stay A->Z under Z->A artists). - The two-term (value, filename) keyset cursor can't seek a three-term order, so artist sorts leave _KEYSET_SORTS and page by OFFSET — measured trivial at real library sizes; title/recent keep their keyset. Restore via a composite sort-key column if 50k-song libraries ever hurt. - The tree view says "List view groups by artist — the selected sort applies to the card grid" when a non-artist sort is active, instead of silently ignoring the picker. - Keyset proof-tests repinned to the title sort (same property, a sort that still keysets); 2 new tests pin the title-within-artist order and the OFFSET pagination's no-skip/no-dupe across pages. Full-suite failure set identical to the same-main baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * v3 library: honor legacy sort=artist&dir=desc (fold dir into effective sort) Codex/review follow-up to the title-within-artist change: the new artist ORDER BY bakes in `ASC` (for the title secondary), so the global `dir=desc` append is suppressed and `sort=artist&dir=desc` silently returned A->Z instead of Z->A — a regression on the legacy /api/library dir contract. Fold `dir=desc` into the canonical sort key BEFORE the sort_map lookup via the existing _effective_keyset_sort helper (same fold the cursor side already does), so the ORDER BY is built from the effective sort. Only artist/title fold (they have `-desc` twins); title/recent/tuning/year/mastery are unaffected — verified by the keyset/filter suites. New test pins that legacy `sort=artist&dir=desc` matches the explicit `artist-desc` ordering (Z->A artists, A->Z titles within each). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
c7497c758d
|
v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (#718)
* v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (R2)
The ⋮ overflow and the native right-click menu already render from one
builder (openCardMenu), so every entry here lands in BOTH surfaces on
grid cards and tree rows alike — parity is structural, not maintained.
New entries (local library):
- Fix match… — opens the match modal in a single-song mode: no queue,
no Skip, the search panel open and pre-filled; a pick pins the match
exactly like the review flow (window.__fbFixMatch).
- Refresh metadata — POST /api/enrichment/refresh/{fn}: resets the
song's match to unscanned (canonical values + candidates cleared,
backoff zeroed) and kicks a pass. An EXPLICIT user action, so it may
discard a manual pin — the automation never does, but the user asking
for a re-match is the one party who owns that pin. Silent on success.
- Get info… — GET /api/chart/{fn}/fileinfo: file location + folder
(selectable/copyable under the v3 no-select default), format, size,
modified; for feedpaks the manifest summary (arrangements, stems,
cover/lyrics presence, authors, and whichever identity keys are
actually authored — mbid/isrc/genres/track/disc); plus the match
verdict ("Matched (text, 96%)" / "Pinned by you" / "Not scanned").
Under /api/chart because the GET /api/song/{path} catch-all would
swallow the suffix.
- Remove from library — with the multi-version interstitial: on a
multi-chart work, "remove the song" is ambiguous (a grouped card
stands for several files), so a modal lists EVERY version with
checkboxes (the card's own chart pre-ticked) and deletes exactly
what was picked — one file or the batch. Single-chart songs keep the
plain confirm.
Refresh + Get info are demo-mode blocked (cache mutation / path
exposure). apply_enrichment_match now zeroes `attempts` on an explicit
reset to unscanned, matching the stub upsert's identity-change rule.
5 new tests (refresh resets even a manual pin then re-matches via the
fake transport; 404s; fileinfo manifest/identity/match shapes;
traversal guard) + the demo-mode route list. Full-suite failure set
identical to the same-main baseline. tailwind.min.css regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 context menu: review fixes — target displayed chart, harden Get-info
Three fixes from the PR review round:
- songs.js: Fix match / Refresh metadata / Get info now act on the DISPLAYED
chart (playTarget) rather than the group representative, matching Play. On a
grouped card where an intrinsic (tuning/arrangement) filter attached a
display_chart, these three previously fixed/refreshed/showed-info for the
wrong file. (__remove stays on `song`: it needs the group's work_key/
chart_count and already pre-ticks the shown chart.)
- server.py fileinfo: 404 ("not a chart") unless the path is a sloppak or a
loose song. The route previously stat'd ANY file under DLC_DIR, leaking its
path/size/mtime for e.g. a notes.txt the user keeps there. `format` can no
longer be "other".
- server.py fileinfo: the directory size sum skips symlinked entries so a link
inside a song folder can't pull in (or leak the size of) a file outside it.
Verified on the runtime that rglob does not descend symlinked subdirs.
+1 regression test (non-chart file -> 404).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
0a8c8945ea
|
v3 library: Albums view — the client half of the album-condense work (#716)
* v3 library: Albums view — the client half of the album-condense work Follow-up to the query_albums endpoint: the UI that consumes it, plus the track-order plumbing the endpoint's track list needs. - Albums view (a fourth view toggle next to grid/tree/folder): album cards (cover / title / artist / track count) from /api/library/albums, respecting the active filter drawer; clicking one opens the track list with per-track play and a Play-album button that feeds the play queue (falls back to plain playSong when the queue plugin is absent). - Track order: the scanner now reads the feedpak `track`/`disc` fields (spec 1.12.0) into new nullable songs columns (idempotent ALTERs), and the album track list orders by the new `track` sort — disc, then track number, unauthored charts to the bottom by title. Charts without authored numbers keep working; they just sort alphabetically. - The albums view persists like the other view choices. 3 new tests: manifest track/disc extraction (and unauthored -> None), the disc->track->title sort order over /api/library, and the put() round-trip. Full-suite failure set matches the known env baseline (one tuner-config name swapped inside the suite-ordering flake family — the file passes 25/25 in isolation on clean main). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * v3 albums: honour Genre/Match filters in album grid + detail (review fixes) The Albums view only partially respected the filter drawer: - /api/library/albums silently dropped the `genre` and `match` params the client sends via queryParams(), so with a Genre or Match filter active the album grid surfaced albums with zero matching tracks. Thread match_states/ genre through the endpoint -> query_albums -> _build_where, mirroring the /api/library grid route. (SmartCollection/pass-through providers keep their existing kwarg handling.) - The album-detail track list built its own params (provider/artist/album/ sort only), so it ignored ALL active filters — the track list and the Play-album queue could include songs the user had filtered out. Reuse queryParams({...}, {catalog: true}) so detail honours the same filters as the grid while pinning this album's artist/album and track order. +1 regression test (albums endpoint honours the Genre filter). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
80caf78306
|
v3 library: genre filter facet (reads feedpak genres) (#690)
* feat(v3): genre filter facet (reads the feedpak genres field) Adds a Genre facet to the library Filters drawer, populated from each song's primary genre. Server: a genre column (idempotent ALTER, indexed) written from the sloppak manifest's genres list on scan (primary = genres[0]); a genre band in _build_where (OR within the selected set); GET /api/library/genres for the facet's distinct list. Client: a multi-select Genre section mirroring the tuning/mastery facets. Follows the merged spec 1.12.0 genres field (#40). v1 stores only the PRIMARY genre (genres[0]); secondary genres aren't filterable yet. Threaded like the mastery filter (separate query_page kwarg, so query_artists /query_stats are unaffected) -- genre filters the grid view. Needs a rescan to backfill genre on existing packs (only packs whose manifest carries genres). Verified live: a sloppak tagged genres:[Metal, Rock] -> /api/library/genres returns [Metal]; ?genre=Metal returns it; ?genre=Rock (secondary) returns none; a plain song stays ungenred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3): scope genre facet to local provider (PR #690 review) The /api/library/genres facet always read the local meta DB, so a remote provider showed local genres while the `genre` filter was a no-op on that provider's grid. Make the endpoint provider-aware: return an empty facet for remote providers (kind != "local") and keep serving genres for the local library and its smart collections, which share the local DB. The v3 client now passes the active provider, mirroring the tuning-names facet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
7ff9261000
|
feat(v3): show "N added / M removed" after a library scan (#686)
Completes the Refresh feature: the scanner now reports a delta so the toast can say what changed instead of a generic confirmation. Server: delete_missing returns both deltas from its one query -- rows pruned (removed) and current files not yet in the DB (added) -- and the scan retains added/removed on the terminal scan-status (previously wiped to 0). Client: the completion toast shows "N songs added / M removed" (or "up to date"), and a scan we merely attached to (background / Settings) toasts only when it actually changed something, so a periodic no-op pass stays silent. library:changed now carries the delta too. Verified end to end: empty rescan -> added 0; add a song -> added 1; remove it -> removed 1. Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
11c0f0483f
|
feat(server): query_albums endpoint for album-condensed browse (#689)
Adds GET /api/library/albums + MetadataDB.query_albums (plus the local and smart-collection provider delegations): distinct (artist, album) groups with a track count and a representative cover song, paged by album, honoring the same filters as /api/library (including the new mastery bands). Rows with no album name are excluded. Album detail needs no new endpoint -- it reuses the existing /api/library?artist=&album=. Backend foundation for the album-condense "Albums" view + play-album; the client view consumes it next. Verified: 4 tracks sharing one album -> /api/library/albums returns one card (artist, album, count=4, cover); /api/library?album=... lists its 4 tracks. Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
74cd08f765
|
library: MusicBrainz text matching + Match-Review UI (P8) (#710)
* library: MusicBrainz text matching + Match-Review UI — P8
Replaces the enrichment plumbing's no-op matcher (P7) with the real
pipeline, per the library-metadata design: a wrong match is worse than a
slow one, so medium confidence goes to a human review queue and never
straight to canonical values.
- lib/mb_match.py (new, pure — no network/DB/server imports): denoise
(author credits, (440Hz)/(Live)/(No Lead)/(v2) parentheticals,
diacritics/punctuation, ACDC / AC DC / AC/DC folding via compacted
token equality), token-set similarity, scoring with year/duration
corroboration bonuses, tier classification (auto needs combined
>= 0.95 AND per-field floors — a perfect-title cover by the wrong
artist, or a chart with no artist, can never auto-match), Lucene
query building, MusicBrainz response normalization.
- Matcher precedence in _enrich_one: content-hash cache copy (another
chart of the same recording matches with no network) -> manifest
mbid (tier 0) / isrc (tier 1) exact keys, feature-detected and
strictly shape-validated, read-only -> text search tiers
(auto / review / failed).
- Lifecycle: review rows store their ranked candidate list (JSON) and
write NO canonical fields until a human accepts; failed rows retry on
an exponential backoff (1 h doubling, 7 d cap) via the attempts
column; user-rejected rows never auto-retry; an identity edit
re-queues anything and resets the backoff; never-overwrite-manual is
enforced inside the single writer (apply_enrichment_match) so no call
path can forget it.
- Network: _mb_http_get is the one transport seam — throttled to
<= 1 req/s through P7's _enrich_throttle, identified with a real
User-Agent from VERSION, and a 503 pauses the whole pass without
burning attempts. Offline guard: no sockets under
FEEDBACK_ENRICH_OFFLINE or FEEDBACK_SKIP_STARTUP_TASKS, so pytest can
never reach MusicBrainz; the pass still stamps identity hashes
(two-phase), which is why every P7 test passes unchanged.
- Routes: GET /api/enrichment/review, POST
/api/enrichment/review/{filename}/accept|reject|pick, GET
/api/enrichment/search (throttled manual-search proxy). All four are
demo-mode blocked.
- Match facet: match= CSV accepted by /api/library AND
/api/library/stats (the A-Z rail's letter counts stay lockstep with
the grid) — review / matched (incl. manual) / unmatched / pending,
the same EXISTS idiom as the mastery facet.
- UI: static/v3/match-review.js (new, self-contained) — an ambient
"N to review" chip beside the song count (rendered only when
non-zero; silent on success, no toasts), and a review drawer on the
filter-drawer slide idiom (Escape + focus trap; row click accepts,
"Not a match" rejects, "Search instead" is the fix-match escape
hatch). songs.js gets the chip mount, a Match filter section, and
session-only match state; also fixes the latent applySavedPrefs bug
where restored filters dropped the mastery key, which made the
filter drawer throw for anyone with saved prefs.
- static/tailwind.min.css regenerated (scripts/build-tailwind.sh) for
the new utility classes; conflicts with sibling PRs resolve by
re-running the script.
Nothing is ever written to pack files — canonical values live only in
the song_enrichment display cache. Cover art caching and acoustic
fingerprinting are follow-up slices.
22 pure unit tests + 19 server tests (fake transport injected over the
_mb_http_get seam) + demo-mode route cases; full-suite failure set
A/B-identical with the change stashed vs applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* library: match-review modal + configurable auto-apply confidence (P8 R0)
Follow-up to the initial P8 commit, folding in the first round of tester
feedback on the review surface and the matcher's knobs:
- Review GUI is a centred MODAL now, not a sidebar — one chart at a
time (the scraper-review model from media-server / emulation-frontend
apps): the chart's current metadata with explicit amber
"Missing: album / year / cover art" chips (art detected via the art
request failing), candidates each carrying "Adds: year - genres -
ISRC" / "Shows as: ACDC -> AC/DC" per-field chips, and Skip /
Not a match / Search instead / Use selected with prev-next + arrow-key
navigation. Chip + window API surface unchanged, so songs.js needed no
edits for the rework.
- Auto-apply confidence is a SETTING: default drops 0.95 -> 0.90
(mb_match.AUTO_MIN; classify() takes an auto_min override). The
per-field floors are untouched and threshold-independent — a
perfect-title cover by the wrong artist still can't auto-match at any
setting. New validated settings keys: enrich_enabled (bool) +
enrich_auto_threshold (0.5–1.01; >1.0 = "Always review", since a
capped score can equal exactly 1.0). Read once per pass; disabling
gates only the BACKGROUND matcher — manual search/fix stays available.
- Settings -> Library -> "Metadata matching" card: enable toggle,
confidence select (85 / 90 / 95 / Always review), a Match Now button
(new POST /api/enrichment/kick, single-flight like every other kick,
demo-mode blocked), and a live status line fed by the same fetch as
the review chip. Markup in index.html per the v3 settings pattern,
wired by match-review.js, null-guarded so v2 no-ops.
- Review queue orders missing-data charts first — confirming those has
the most to gain; complete charts only stand to be re-labelled.
Tests: threshold moves the auto/review boundary via settings; the
enable toggle gates matching but not the manual proxy; settings
validation; kick route; queue ordering; classify(auto_min=...) floors.
Full-suite failure set byte-identical to the pre-change baseline.
tailwind.min.css regenerated for the modal's utility classes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(library): lock MusicBrainz throttle across sleep + de-dup enrich queue (PR #710 review)
Hold a module-level lock across _enrich_throttle's read/sleep/write so the
background daemon and threadpooled sync search route serialize outbound MB
requests instead of bursting past the 1 req/s limit. De-dup the enrich queue
by filename so a changed-hash failed row isn't processed twice per pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
7c15cdda66
|
library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7 (#707)
* library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7 The pipeline around a deliberately NO-OP matcher, so the real MusicBrainz text matcher (next slice) replaces exactly one function and inherits the queue, throttle, lifecycle, and safety contracts: - song_enrichment cache table: one row per song holding the match lifecycle (unscanned -> matched(source,score) | manual | failed) plus the canonical values a confident match supplies. A DISPLAY cache - canonical values are never auto-written into pack files. Never purged on rescan (only by the explicit per-song delete); dead rows filtered at read time; re-derivable, so a lost row just re-enriches. - Identity hashing: sha1 of normalized artist|title|album|duration. Filename-free, so a renamed pack keeps its enrichment; unchanged hash makes re-enrichment a no-op (idempotent). - Queue rules (test-pinned): no row / unscanned / identity-changed -> re-match; matched + current hash = settled; a MANUAL row is the user's pinned pick and is never auto-reset (state and hash both survive metadata edits); failed waits for the matcher's backoff policy (attempts column ready). - Worker: _kick_enrich/_enrich_runner mirror the scan's single-flight + coalescing pattern, kicked when a scan pass fully completes (the scan pool is a no-network process pool by design; the 5-minute periodic rescan is the natural retry hook). One bounded pass per kick - no drain-loop, since the no-op matcher legitimately leaves rows unscanned. _enrich_throttle() is the <=1 req/s seam every matcher must call before a network request, and the never-hold-meta_db._lock- across-a-fetch rule is documented at the seam. - CONFIG_DIR/art_cache dir helper (the cover-art slice adds the LRU cap) + GET /api/enrichment/status (worker flags + counts by state). 8 new tests; full-suite failure set identical to unmodified main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * fix(library): lock enrichment reads on shared conn + skip redundant stub writes (PR #707 review) Issue 1: wrap the SELECT+fetch in enrichment_pending, get_enrichment and enrichment_state_counts in self._lock so request-thread reads no longer interleave with the worker's execute+commit on the shared connection. Issue 2: guard upsert_enrichment_stub so an already-settled row (manual pick, or a non-manual row whose content_hash already matches) skips the UPDATE/commit — stops the no-op matcher re-writing every song each pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
f6d8e241eb
|
v3 library: curated album (kind='album' + per-slot chart/arrangement pins + play-album) — P6 (#706)
* v3 library: curated album — your version of an album, one chart per slot — P6
A curated album is a hand-picked, ORDERED practice set of works with a
chosen chart per track (metadata-design 7.2) - the repeatable gameplay
loop. No new tables: a playlists row with kind='album' plus two per-slot
columns.
- Schema (additive, idempotent): playlists.kind ('album' | NULL=mix),
playlist_songs.arrangement (the pinned arrangement NAME - names
survive rescans; the index is resolved at play), playlist_songs.
work_key (stamped at ADD time = "resolved to preferred once at add,
pinned thereafter").
- Orphan-at-read self-heal: an album keeps every slot. A slot whose
pinned chart was deleted resolves to the work's CURRENT keeper at
read (marked "(auto)"; membership is never rewritten - if the file
returns, the slot resolves back to itself), and reports missing when
the whole work is gone so the set's denominator stays honest. Mixes
keep hiding dead songs byte-identically.
- Slot editor: PATCH /api/playlists/{pid}/songs/{fn} pins/clears the
arrangement and/or swaps the slot's chart - validated to the SAME
work via the stored stamp, position + pin kept, duplicate members
rejected. The per-slot pick is independent of the work's global
preferred: a rehearsed set stays the same notes even if the global
keeper is re-picked later.
- UI: "New album" on the Playlists screen (album chip + disc cover);
the album detail adds a set-scoped "Album repertoire" meter (N of M
mastered - per-track mastery, never one album score), per-track
accuracy, and a per-row slot editor listing only the work's charts.
"Play album" runs the play-queue front-to-back honoring pins (the
queue already supported per-index arrangements); per-row play uses
the resolved chart + pinned arrangement.
12 new tests; playlists/collections regressions green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3): count all album slots (list vs detail parity) + surface slot-edit PATCH failures (PR #706 review)
_playlist_count applied the mix "dead-filter" to every playlist, so an album's
list-card count dropped orphaned/missing slots that its detail view still
renders and plays (5-track album, 2 pins deleted → card "3" vs detail 5). Count
ALL slots for kind='album' (mirroring get_playlist's is_album discriminator);
mixes/other kinds keep the dead-filter. openSlotPicker's Apply now checks the
jsend return and, on a rejected PATCH (swap-to-other-work / duplicate pin),
shows an inline error and keeps the picker open instead of closing as success.
Adds album count-parity + mix dead-filter regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
e9d95ad190
|
v3 library: multi-chart work grouping, complete (engine + API + card + drawer + toggle/split/filter-law) — P5a–P5e (#702)
* v3 library: multi-chart work grouping engine + work-charts API — P5a/P5b
Charts of the same song (same normalized artist+title) now GROUP under a
computed work_key, with a materialized representative filter so the grid
can collapse them without breaking keyset paging:
- work_key = normalize(artist+title) (diacritics/punct/case folded,
leading "The" folded on artist); resolves the effective artist via the
artist_alias table when present (feature-detected, no hard dep).
- Sparse, never-purged-on-rescan tables: chart_group_pref(work_key,
preferred_filename) + chart_group_split(filename, split_key); purged
only by the explicit per-song delete.
- Materialized work_display(filename, work_key, effective_work_key,
is_group_representative, group_size) read-model: lazy rebuild via a
dirty flag set on put/delete; set_chart_preferred does an incremental
re-flip (no full rebuild). Auto-pick representative = most
arrangements -> most plays -> newest -> filename; a user pref wins and
degrades to auto-pick if its file disappears.
- group=1 on query_page/query_stats = one extra representative
predicate applied identically to page + total + sort_letters, so the
keyset cursor (sort_value, filename) stays a valid total order and
counts works, not charts. Grouped rows carry chart_count + work_key.
- Charts API: GET /api/work/{work_key}/charts (members + which is the
keeper, your pick vs auto), PUT/DELETE .../preferred, and
POST /api/chart/{filename}/split + /unsplit (under /api/chart so the
DELETE /api/song catch-all can't shadow them).
Tests: 15 grouping-engine + 7 charts-API tests, including grouped
keyset pagination (no skip/dupe across pages).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: grouped grid card + persistent "N charts" chip — P5c
Flip the v3 grid to the grouped library (group=1 on /api/library and the
rail's /api/library/stats fetch): one card per song, showing the
representative (preferred/auto-pick) chart. group rides page, total and
sort_letters identically so the A-Z rail's cumulative-seek math and the
virtualized sizer stay consistent, counting works not charts; the keyset
cursor chains with group on every page.
- New groupingActive() helper, default ON per the design; the persisted
per-view toggle (P5e) lands there. Only the local provider implements
group=; smart collections and remote providers ignore it and stay
flat, so it is safe to send unconditionally.
- chartsChipHtml(): a "flag N charts" chip rendered ONLY when
chart_count >= 2 - single-chart cards emit byte-identical markup.
First in the fixed-height chip row + shrink-0 so it never clips and
card height is unchanged.
- Chip click = feature-detected window.__fbOpenChartsDrawer (the Charts
drawer arrives in P5d); until then a no-op. Plain-click / play / the
arrangement chips are untouched and play the representative.
- The library-home repertoire meter's stats fetch deliberately stays
ungrouped: its mastered numerator counts chart filenames, so a works
denominator could exceed 100% - reconciling that is P5e's
mastery-anchor work. The tree view stays flat (query_artists has no
grouping; its opener is wired in P5d).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: Charts drawer + openers — P5d
The single deep-management surface for a work's charts (design UX-2/3):
a body-appended slide-in drawer (filter-drawer idiom) listing every
chart of the work as a radiogroup — the checked row is the keeper the
grid card plays.
- Rows show format / tuning / arrangements / year / your accuracy (or
"not played") plus the pack filename, usually the only human-readable
distinguisher between duplicate charts. Keeper is labeled
"Preferred (auto)" vs "Preferred - your pick".
- Row click (or Enter/Space) = one-tap Set-preferred; "Reset to auto
pick" appears when the keeper is an explicit pick. Writes go through
the work-charts API and the drawer re-renders from the response; the
grid re-fetches in place since the representative may have flipped.
- Per-row Play (plays that exact chart) and Add-to-playlist (the picker
is z-[200], layering over the z-50 drawer).
- a11y: Tab focus-trap, Escape closes, ArrowUp/Down move focus between
rows (focus only - arrow-select would fire a preferred write per
keystroke), focus restored to the opener on close.
- Openers: the "N charts" chip opens the drawer directly; the card's
overflow menu gains "Charts (N)..." and "Play version >" (expands
inline; picking one plays it as a one-off - the keeper/headline does
not move). Tree rows ride the ungrouped artists endpoint, so the menu
resolves their work lazily via the new GET /api/chart/{fn}/work
({work_key, chart_count}) and slots a "Charts (N)..." entry in when
versions exist. A window.__fbOpenChartsDrawer global lets other views
open the drawer. Right-click is deferred: the open native card
context-menu PR should host that entry once both merge.
- tailwind.min.css rebuilt: carries the new utility classes from this
and the previous commit (the grouped-card chip tint was missing).
Split keys contain '#', so clients MUST URL-encode work_key in paths
(the v3 client does; a test documents the round-trip). 4 new endpoint
tests; 26/26 grouping+charts tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: group toggle, split UI, the filter law + mastery-anchor rules — P5e
Completes the multi-chart grouping slice (design 7.1):
- Filter law under group=1: work-identity (artist/album/search) and
practice-state (favorites/mastery/tags/difficulty) predicates stay on
the representative row, while CHART-INTRINSIC predicates (format/
arrangements/stems/lyrics/tuning) now match if ANY member of the work
does - a song you own in Drop D is no longer hidden because your
preferred chart is E Standard. Intrinsic clauses moved to an
alias-aware builder and re-applied as a member EXISTS; identical in
query_page and query_stats so counts and the A-Z rail stay in
lockstep. A pure predicate - keyset paging is untouched (tested).
- Display-chart switch: when the representative itself doesn't match,
the row carries a display_chart override (the matching member). The
row stays the representative's - swapping rows wholesale would break
the (sort_value, filename) cursor - and the card renders/plays the
member while the accuracy badge and heart stay anchored on the
preferred chart.
- Mastery sort aggregates MAX across the group ("a song surfaces on any
chart you've touched"); OFFSET-paged, so cursor-safe. The
Recently-Added aggregate is deliberately deferred: mtime IS a keyset
sort, so its aggregate would need materializing into work_display.
- History-sticky auto-pick: most-played -> most-complete -> newest.
A newer/"more complete" import can't silently take the pick from the
chart your reps accrued on, and a one-off try of an alternate can't
out-rank a practiced incumbent; all-unplayed groups still pick by
completeness.
- Persisted "One card per song" toggle in the filter drawer (default
ON; OFF = one card per chart). A view mode: never counted in the
filter badge, never saved into collection rules, local provider only.
- Split escape hatch: "Split out" per drawer row gives a chart its own
card; the split card's overflow menu offers "Rejoin other versions"
(rows and the chart-work lookup now carry is_split).
- Mastery-anchor heads-up: after set-preferred the drawer shows a
one-line ambient note that practice history stays with each chart
(no toast - hearing-safe).
10 new filter-law tests; 38/38 grouping tests green. tailwind.min.css
rebuilt for the new utility classes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3): work-grouping — escape Charts-drawer meta (XSS), keep non-Latin titles distinct, guard mid-rebuild reads (PR #702 review)
- XSS: esc() the composed `meta` string in _chartRowHtml (arrangement/tuning
names come from untrusted feedpak metadata) before innerHTML; acc stays HTML.
- Non-Latin titles: _norm_token falls back to raw lowercased whitespace-collapsed
text when the NFKD+strip fold yields "" (CJK/Cyrillic/Greek/Arabic), so distinct
non-Latin titles keep distinct _work_key values instead of collapsing into one
bogus work. Latin names still hit the folded branch — behavior unchanged.
- Mid-rebuild reads: wrap the grouped representative SELECT in query_page and
query_stats under self._lock (nullcontext when ungrouped, so lazy reads stay
lock-free) so a reader can't observe work_display between rebuild_work_display's
DELETE and INSERT/commit. _ensure_work_display stays OUTSIDE the lock — it
self-locks the rebuild and self._lock is non-reentrant — so only the SELECT is
guarded (rebuild fully completes before the guarded SELECT runs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
a47accd894
|
v3 library: artist aliases + Tidy-up merge UI — P4 (#705)
* v3 library: artist aliases + Tidy-up merge UI — P4 Fixes the "ACDC vs AC/DC" split without touching a single file or row: a never-purged artist_alias table (raw_name -> canonical_name) applied at DISPLAY time. The scanner keeps writing whatever the pack says; one alias row fixes every matching song. - query_artists dedupes/groups/orders on the effective artist, with a zero-cost fast path when no aliases exist; the artist filter expands a canonical name to its raw variants (index-friendly, keyset-safe); query_page re-labels row artists through the alias map. - CRUD + merge API: list aliases, list raw artists (variants + counts for the picker), set/merge/remove; a self-alias clears (= un-merge). - "Tidy up artists..." in the filter drawer (local library only): a searchable raw-variant checklist, merge-into-canonical, and a current-merges list with per-row un-merge. The artist dropdown + tree pick up canonical names with no dropdown code changes. - Sort + A-Z rail stay on the RAW artist (keyset-safe): a cross-letter alias shows its canonical label but buckets under the raw letter until effective columns are materialized (the grouping engine's work_key already resolves aliases when this table exists, so merged artists group correctly there). 11 tests. tailwind.min.css regenerated (generated file - on a merge conflict, re-run scripts/build-tailwind.sh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * fix(v3): flatten transitive artist-alias chains + cycle guard so sequential merges unify (PR #705 review) merge_artists looped set_artist_alias which stored one hop, so sequential merges (ACDC->AC/DC then AC/DC->AC-DC) left a two-hop chain that the single-hop effective_artist/grouping/filtering split into two groups. Add _single_hop_canonical + _terminal_canonical (visited-set cycle break), resolve the canonical to its terminal before storing, forward-flatten existing rows that pointed at the raw name, and reject cycles (409). Batch merge now runs under one lock + one commit for atomicity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
77e5a4982b
|
v3 library: growth-edge "practice next" recommender — P3 (#704)
* v3 library: growth-edge "practice next" recommender — P3 The "Keep practicing" shelf stops being recency-only: a new GET /api/library/practice-suggestions ranks started-but-unmastered songs by difficulty-appropriateness x mastery-proximity (the growth edge - the mid-difficulty, closest-to-mastery material where practice pays off fastest), and the shelf sources it instead of filtering /api/stats/recent. - Score = difficulty band fit (your 1-5 rating; unrated degrades to the middle band so the shelf works before any ratings exist) x proximity to the 0.9 mastery threshold. Read-only - never writes difficulty. - A shelf click opens the closest-to-mastery arrangement. - Per-arrangement difficulty and seed-from-authored intentionally NOT faked: there is no authored/derived difficulty on songs yet (the feedpak difficulty spec is unmerged) and the personal rating is per-song - both revisit when that field lands. 9 endpoint tests. tailwind.min.css regenerated (generated file - on a merge conflict, re-run scripts/build-tailwind.sh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * fix(v3): deterministic tiebreak (filename) in practice-next ordering (PR #704 review) Add r["filename"] as the final sort component so suggestions with equal growth_score and equal/None last_played_at order deterministically instead of by SQLite's unordered agg.items() scan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
feaaa5cd81
|
v3 library: song Details drawer + bulk edit — P2 (#703)
* v3 library: song Details drawer + bulk edit — P2 Evolves the per-song editing surface from the legacy modal into a v3 slide-in Details drawer (the filter-drawer idiom, body-appended): catalog fields (title/artist/album/year, written through the existing atomic manifest writer) plus the P1 personal layer - your difficulty (1-5), tags, and notes - with the heart staying the existing favorite system. - Cards badge the personal layer at rest (difficulty pip + tag count, top-right, fading on hover so the action buttons keep that corner); un-annotated cards render byte-identical to before. - Bulk edit from the select-mode batch bar: POST /api/songs/user-meta/batch applies additive tag add/remove and a leave/set/clear difficulty across the selection (mixed-state aware). - The core card action relabels to "Details" and opens the drawer via a feature-detected global, falling back to the legacy modal when the drawer isn't mounted. 16 batch tests new; the P1 user-meta suite stays green. tailwind.min.css rebuilt for the drawer's utility classes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * fix(v3): surface bulk-edit / save-details request failures instead of reporting success (PR #703 review) Check the batch/write responses and show an fbNotify error (keeping selection and drawer) instead of unconditionally closing as success. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
58e7407c38
|
v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1 (#691)
* v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1
The local "your relationship to the song" layer: a per-song user-difficulty
(1–5, planning-only, distinct from the authored 1–10 difficulty bands),
free-form notes, and free-form practice tags. All kept OUT of the shared
feedpak file and OUT of the `songs` table, in two new never-clobbered tables
so a rescan's `INSERT OR REPLACE INTO songs` can't wipe them. Likes stay the
existing favorites heart — no rating column.
Backend only (the Details drawer + tag/difficulty filter UI come next).
Schema (additive, idempotent):
- song_user_meta(filename PK, user_difficulty INTEGER, notes TEXT, updated_at)
- song_tags(filename, tag, created_at, PK(filename, tag)) + idx on tag
API (DB-only — distinct from POST /api/song/{f}/meta, which writes catalog
fields back into the file):
- GET /api/song/{f}/user-meta → {user_difficulty, notes, tags}
- PUT /api/song/{f}/user-meta → partial update; user_difficulty (1–5 or
null), notes (string or null), tags (full-replace array). Tag removal is a
full-replace array rather than a DELETE sub-route because the greedy
DELETE /api/song/{filename:path} already owns every DELETE under /api/song
and would shadow it.
- GET /api/tags → tags in use with counts (for the filter UI)
Read path:
- query_page rows embed user_difficulty + tags (like `favorite`); notes stay
out of the list payload (per-song GET — they can be long).
- Read-time filters ?user_difficulty= and ?tags= threaded through _build_where
exactly like the mastery filter — EXISTS-style predicates, so keyset paging,
counts, and the A–Z rail are unaffected.
- delete_song purges both personal tables inside the existing lock.
Tags are normalized (trim + lowercase + collapse whitespace) so "Rock"/"rock"
don't split. New tests cover defaults, difficulty validation (rejects out-of-
range / non-integral / bool), notes trim, tag normalize/dedupe, grid embed,
both filters, never-clobber-on-rescan, and purge (21 tests). Neighboring
library tests (filters/keyset/providers/playlists/collections/stats) stay
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(v3): cap per-song tags at 50 to bound writes (PR #691 review)
set_song_tags capped each tag at 60 chars but not the number of tags,
so one PUT could write unbounded rows. Cap the normalized-unique tag
list to the first 50 after dedup. Adds a test asserting >50 stores 50.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
744c848636
|
v3 library: mastery filter + sort (needs-practice / most-mastered) (#687)
* feat(v3): sort the library by mastery (needs-practice / most-mastered)
Adds two sort options to the Songs library: "Needs practice first" (weakest
measured accuracy first) and "Most mastered first". Mastery = MAX(best_accuracy)
across a song's arrangements, from song_stats; because that's a separate table
it's a correlated subquery in the ORDER BY, so these sorts use OFFSET paging like
tuning/year. Unscored ("not started") songs always sort to the bottom in both
directions, so a large unpracticed library doesn't bury the songs you're
actually working on. Never the default.
Verified against a running server: scored songs at 0.90 / 0.30 plus unscored ->
ascending orders 0.30, 0.90, unscored; descending 0.90, 0.30, unscored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* feat(v3): add the 3-state mastery filter (Mastered / In progress / Not started)
Complements the mastery sort with the gentler filter the charrette preferred: a
"Progress" facet in the Filters drawer with Mastered (>= 0.9), In progress
(attempted but < 0.9), and Not started (no score) -- multi-select, OR within the
set. Server-side via a correlated subquery on song_stats threaded through
_build_where / query_page (passed as a separate kwarg so query_artists /
query_stats are unaffected). Smart-collection providers ignore it (they define
their own filters).
Verified live: mastered -> the 0.90 song, in_progress -> the 0.30 song,
not_started -> the unscored songs, combined -> both scored (with correct totals).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
c4bb58233d
|
feat(core): route the highway chart to the selected instrument's part (working-tuning PR 2) (#659)
When a song loads without an explicit arrangement, highway_ws now reads the player's selected `instrument` from config.json (the same file it already reads for the default-arrangement preference) and picks the arrangement that matches: bass -> the Bass part. Guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed the default Lead/guitar chart, and the working-tuning coverage check then compared a 4-string bass against a 6-string part (always "can't cover"). This is the instrument->chart routing the working-tuning series leans on. Server-only (every launch path flows through the WS, so no client change). An explicit arrangement request always wins, so only the default part chosen on load changes. Tests: tests/test_highway_ws_instrument_routing.py (bass->Bass, guitar->default, explicit-wins) — 3 new, existing highway WS tests still green. Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5ed6f454e7
|
feat(library): smart collections as a library provider (#641)
Implements feedBack#636 item 2 (P1) — saved library filters that stay live, the homelab primitive FeedBack was missing (Plex smart collections / Navidrome .nsp / *arr custom filters). A collection is a saved /api/library query surfaced as a registered library provider, so it appears in the v3 source picker and inherits the whole Songs UI (paging, stats, A–Z rail, art) with no new screen. - Storage reuses the playlist subsystem: a `playlists.rules` JSON column (additive, idempotent migration). A row with rules != NULL is a smart collection; list_playlists + get_playlist filter `rules IS NULL`, so collections are excluded from the manual-playlist list and read-only to every playlist mutation that gates on get_playlist. - SmartCollectionProvider (kind="local" — matched songs are local rows, so the client's play/art paths stay on the local branch) delegates query_page/ query_stats/query_artists to the local DB with the stored rules applied; tuning_names/get_art delegate straight through. Registered via a boot scan + on create/update (replace=True) / delete. - Rules mirror the raw /api/library query params; `_sanitize_collection_rules` drops unknown keys and is applied at API ingress AND on provider load, so a hand-edited / imported bad value can't crash a query. - API: GET/POST/PUT/DELETE /api/collections. Frontend: a "+ Save as collection" action in the v3 filter drawer (local provider + active filters only) that names the current filter set and switches to it. Reviewed by Codex; 3 findings fixed (local-kind playback path, save gated to local provider, re-sanitize persisted rules). Tests: tests/test_collections_api.py (CRUD, provider filtering, restart re-registration, kind=local, corrupt-rule tolerance, playlist isolation), tests/js/v3_collections.test.js. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
331857ff2a
|
feat(library): keyset cursor pagination + stable sort tiebreak (#636 item 3, stage 1) (#642)
Stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3): the data layer the DOM-recycling render window will build on, plus a latent paging bug fixed on the way. - Every grid sort now appends a unique `filename` tiebreak → a TOTAL order. Without it, rows with an equal sort key (e.g. two songs by the same artist) could be skipped or duplicated across OFFSET pages. - query_page gains an opaque `after` keyset cursor: when supplied and the sort can keyset (artist[-desc], title[-desc], recent), the page is fetched with a WHERE-seek instead of OFFSET — O(page), independent of depth. The seek is NULL-aware (NULLs first in ASC / last in DESC) so it's EXACTLY OFFSET- equivalent; the legacy `dir=desc` shape is canonicalized so its cursor seeks the right direction. Unknown/compound sorts + bad cursors fall back to OFFSET. - /api/library exposes `after` + `next_cursor`. Only the true local provider is handed a cursor (a collection may pin a different sort; remote don't keyset), so both page by OFFSET safely. - Composite (artist NOCASE, filename) / (title NOCASE, filename) / (mtime, filename) indexes cover the order; `after` added to the optional provider kwargs so legacy providers drop it. Codex-reviewed; 3 findings fixed (dir=desc canonicalization, NULL-key seek, cursor only for the local provider). Tests: tests/test_library_keyset.py (keyset==OFFSET parity for 5 sorts, stable tiebreak on equal keys, dir=desc, NULL sort keys, bad-cursor + compound-sort fallback). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8ca7ea4002
|
feat(library): persisted wishlist / "wanted" list (#640)
Closes feedBack#636 item 4 — the *arr "Wanted/Monitored" analogue FeedBack was missing. A wishlist entry is a song the user does NOT own yet, so unlike a playlist (which references owned local songs by filename) it can't reuse the playlist subsystem; it lives in a new `wanted` table keyed by descriptive identity (artist, title, source, source_ref, note, created_at). - New table + a UNIQUE index on (artist NOCASE, title NOCASE, source, source_ref); additive + idempotent (CREATE … IF NOT EXISTS). - MetadataDB.add_wanted (INSERT OR IGNORE + re-select under the write lock, so a re-run of an ownership-diff returns the existing row, never a dup), list_wanted (newest first), remove_wanted, count_wanted. - Routes GET/POST/DELETE /api/wanted. POST requires artist or title and defaults source to "manual"; idempotent on identity so producers (the find_more ownership-diff, or a manual add) can re-post freely. This is the core persistence primitive the charrette flagged as the missing piece; the consuming UI lives in the producing plugin (find_more / the_daily). Tests: tests/test_wanted_api.py (round-trip, identity idempotency incl. case-insensitive, distinct source_ref, ordering, validation, additive schema). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
07ab902604
|
feat(settings): back up the library DB + custom art in the export bundle (#639)
Closes the dev-ops lens's #1 finding from the library charrette (got-feedback/feedBack#636 item 1): scores, favorites, playlists, and play history — the only library state a rescan can't rebuild — were absent from the settings backup. Now GET /api/settings/export carries an additive `core_server_files` section: - a CONSISTENT snapshot of web_library.db via the SQLite online-backup API (a complete single file even while the server runs; taken under the MetadataDB write lock), base64-encoded; - custom playlist covers + avatar (CONFIG_DIR/playlist_covers, /avatars), walked with the existing _walk_export_paths machinery. Restore is DB-safe: - POST /api/settings/import STAGES the DB to web_library.db.restore (never over the live, open file); _apply_pending_db_restore swaps it in at the next startup BEFORE the connection opens, clearing stale -wal/-shm so a stale WAL can't be replayed onto the restored file. Response sets `restart_required` + a warning; custom art applies immediately. - The staged DB is integrity-checked (open + PRAGMA quick_check) at import AND again at startup before the live DB is touched — a corrupt/truncated restore is refused/discarded and the live DB is left intact, so a bad bundle can never brick startup or lose data. - Export hard-fails (500) if the snapshot can't be produced (no silent DB-less backup); a partial import disarms its own staged restore. Backward-compatible: older servers ignore the new section; a bundle without it imports as before. Known gap: custom uploaded *song* art is still commingled with the rebuildable thumbnail cache in art_cache/, so it isn't bundled yet (tracked follow-up on #636). Tests: tests/test_settings_export_library_db.py (snapshot consistency, staged-not-live restore, sidecar clearing, corrupt-DB refusal at import + startard, traversal rejection, export hard-fail, disarm-on-failure, full round-trip). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6a71577e05
|
feat(v3 library): A–Z fast-scroll jump rail on the Songs grid (#634)
* feat(v3 library): A–Z fast-scroll jump rail on the Songs grid Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar so you can jump the library to a starting letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between letters. The classic (v2) tree already had letter selection; this brings the new v3 grid to parity (it was the gap behind the "alphabetical scroll selection next to the scrollbar" idea). It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only offers letters present in the current sort AND filter set, so a tap always lands on a real card (absent letters are dimmed + non-interactive). The grid is forward-only, server-paged infinite scroll with no virtualization, so a jump pages through to the target card then scrolls to it; a token guards overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized window is the scaling follow-up for very large libraries. Backend: /api/library/stats gains an optional `sort` param and an additive `sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist or title), filter-synced — so the rail's present-letters match the grid's real order. The legacy `letters` (distinct-artist) field is unchanged, so the dashboard + classic tree are unaffected. `sort` is dropped for providers whose query_stats predates it (existing kwarg-filter), so third-party library providers keep working (rail simply falls back / hides). Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag + keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble). Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs- distinct-artist counting), tests/test_library_providers.py (sort forwarded), tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3 library): harden A–Z jump rail (review P2/P3) Addresses the PR #634 review findings (manual + Codex): P2 correctness - refreshRail prefers the active-sort `sort_letters`; falls back to the artist-based `letters` only on an artist sort, and hides the rail on a title sort when a legacy provider returns none (was mislabeling letters). - reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a grid that's being rebuilt from page 0. - songBucket no longer trims, matching the server SQL + grid ORDER BY raw first-char bucketing (a leading-space title now buckets under '#' on both sides). P3 polish - Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a magic 4000, keeping large libraries reachable while still bounded. - Roving tabindex: only the first present letter is tabbable; arrow keys move it. Removes up to 27 page tab stops. - `sort_letters` is computed only when the caller opts in (want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree skip the extra GROUP BY. Added sort + want_sort_letters to the optional provider-kwargs so non-introspectable legacy providers drop them. - _railToken supersedes stale refreshRail responses; hide the rail when no letters are present instead of rendering disabled buttons. Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
8a2175aa1c
|
feat(onboarding): amp-sim opt-in choice + use_amp_sims setting (#631)
Second half of feedBack-desktop#46. The desktop app monitors through an
in-app amp-sim/tone chain that, once loaded, auto-restores every launch —
an idle high-gain amp on the input is a constant distorted buzz, and the
dry-only monitor mute can't silence it. This adds the "own-rig first"
opt-in so players using their own external amp/rig never get a processed
monitor in the first place.
Core changes:
- New `use_amp_sims` setting (default OFF / own-rig first): GET default,
POST boolean validation, and resettable key — mirroring achievements_enabled.
- Onboarding wizard: a DESKTOP-ONLY step ("How do you want to hear
yourself?") between instrument paths and the calibration challenge. The
web build has no native amp sims, so the step is skipped there (5 steps
on web, 6 on desktop) — gated on window.feedBackDesktop, dot count and
setStep bounds are derived from it. Ticking "Use in-app amp simulations"
POSTs use_amp_sims; default unticked.
The desktop renderer consumes this setting to gate its saved-tone-chain
restore (feedback-desktop PR, paired).
Verified by booting core locally and walking the wizard with Playwright:
web shows 5 dots/no amp step, desktop shows 6 dots, the amp step is
reachable, calibration stays the final "Play it now" step, ticking the box
persists use_amp_sims=true, and there are no page errors. Server-side
GET default / POST validation / reset confirmed via curl.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5a0b62599d
|
feat(highway): show feedpak author/editor credits on song load (#629)
Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start.
Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end.
|
||
|
|
90fb2ee3bc
|
feat(v3): content-dependent playlist covers + custom art (#626)
* feat(v3): content-dependent playlist covers + custom art upload Playlist cards were a tiny 🎵 emoji on an empty square. Now the cover reflects the playlist's contents, and you can override it with a custom image. Cover (in priority order): - custom uploaded cover, else - empty playlist -> the icon - a few songs -> the first song's album art - 4+ songs -> a 2x2 album-art mosaic Backend (server.py): - MetadataDB.list_playlists() returns each playlist's first few still-present songs' art URLs (`art_urls`) for the content cover. - GET /api/playlists and GET /api/playlists/{id} add `cover_url` when a custom cover exists. - POST/GET/DELETE /api/playlists/{id}/cover — store a small PNG thumbnail under CONFIG_DIR/playlist_covers/ (PIL-converted, mirroring song-art upload); the cover is deleted with the playlist. Cover mutators added to _MUTATING_ROUTES. Frontend (static/v3/playlists.js): playlistCoverHtml(p) renders the rules above; the playlist detail view gets "Cover" (pick an image) + "Remove cover". Tests: tests/test_playlists_api.py (art_urls + cover roundtrip / reject-non-image / delete-removes-cover — 11 pass) and tests/js/v3_playlist_cover.test.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(playlists): 400 (not 500) on non-string cover image + bust same-second cover cache Two review follow-ups on the playlist-cover endpoints: - POST /cover did `if "," in b64` before any type check, so a non-string image (e.g. {"image": 123} / null) raised TypeError -> 500. Guard with isinstance (mirrors the avatar/song-art upload) for a clean 400. +regression test covering number/null/object/list. - The cover URL busted only on int(st_mtime) (1s granularity) and GET /cover sent no cache headers, so a same-second replace/remove/re-upload could serve a stale image. Use st_mtime_ns in the cache-bust token and add the shared no-cache header (_ART_CACHE_HEADERS), matching song art. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
287c23a532
|
feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591)
Sharing earned Feats on the (forthcoming) public wall is strictly opt-in,
default OFF, with a binding data-minimization contract.
- Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard)
after song-directory / before paths — publishes only display name + earned
Feats, never songs/skills/scores; off by default.
- Settings (plugins/achievements/settings.html, System tab via
settings.category): the same toggle + a "Remove me from the wall" button
(POST remove-me — wipes local synced state offline + enqueues removal).
- Core (server.py): achievements_enabled (bool, default false) in
_default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS;
mirrored to localStorage in app.js loadSettings().
- Data-minimization gate: engine.build_wall_payload is the single explicit-dict
serializer; key-set is EXACTLY {display_name, player_hash, achievement_id,
unlocked_at}, achievement_id always a Feat id. Enqueue is gated on
opted-in AND profile identity (reused player_hash); competency never
enqueues (integration law).
Verified natively: settings round-trip + validation + remove-me; opted-in
activity enqueues exactly one 4-field Feat payload; Playwright confirms the
5-step wizard + opt-in card (default unchecked), zero console errors.
29 plugin tests + new settings tests pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
3b485fe62b
|
feat(v3): tabbed, card-row settings page + per-plugin settings category (#584)
Replace the single long scrolling v3 settings screen with a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows (icon + title + description, control on the right) with a per-category Reset. - static/v3/index.html: tab bar + card-row markup (ids keep hydrating through the unchanged app.js loadSettings()/persistSetting() path). - static/v3/settings.js (new): tab switching + active-tab persistence (localStorage 'v3-settings-tab'), per-category reset, read-only Keybinds reference from window.getAllShortcuts(). - static/v3/v3.css: plain CSS, no Tailwind rebuild. - Per-plugin settings tab: new optional settings.category in plugin.json → plugins/__init__.py surfaces settings_category; app.js mounts each plugin <details> into #plugin-settings-<category> (fallback: Plugins tab). highway_3d ships category: "graphics". - New gameplay settings: countdown_before_song (wired end-to-end, default off); miss_penalty + fail_behavior (persist-only stubs); "Note highway speed" surfaces existing master_difficulty. - New POST /api/settings/reset clears whitelisted keys back to defaults. Tests: test_settings_api.py, test_plugins.py::test_settings_category_parsed_from_manifest, tests/browser/settings-tabbed.spec.ts. 179 passed locally. Ported from the pre-rename feat/v3-settings-tabbed WIP onto current main (slopsmith→feedBack rename applied; settings-screen markup conflict resolved in favour of the new tabbed layout — all prior setting ids preserved). Closes #579 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |