mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-24 05:41:53 +00:00
8f014e6a30
78 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8f014e6a30
|
refactor(server): carve the library scanner into lib/scan.py (R3b) (#901)
lib/scan.py (326). server.py 2,098 -> 1,870.
The background scan, its spawn ProcessPoolExecutor, and the kick/runner plumbing that
serialises passes. Bodies VERBATIM except the seam reads.
Everything shared is read LATE off appstate — the same contract every module in
lib/routers/ uses, and it is not cosmetic: tests monkeypatch CONFIG_DIR and swap meta_db,
so a value captured at import time pins the wrong one for the life of the process.
CONFIG_DIR -> appstate.config_dir
meta_db -> appstate.meta_db
_default_settings -> appstate.default_settings()
_stat_for_cache -> appstate.stat_for_cache()
━━━ THE SCAN STATUS IS REBOUND, NOT MUTATED ━━━
_background_scan does `global _scan_status; _scan_status = {**INIT, ...}` at every stage
transition. It REPLACES the dict; it never updates it in place. So nothing may hold that
dict by value — a reference captured once goes permanently stale at the first stage change
and would report "listing" forever while the scan ran to completion.
Hence `scan.status()`, a getter, and hence appstate publishes scan_status as a CALLABLE.
appstate.py already said so in a comment; this is the code that makes it true. (Same for
the plugin_context entry, which was already `lambda: dict(_scan_status)` — late-bound, so
it survives the move unchanged. The contract test from #898 covers it.)
━━━ appstate.server_root: A TRAP CLOSED PERMANENTLY ━━━
_background_scan seeds the builtin content, which needs the directory holding server.py.
`Path(__file__).resolve().parent` is correct in server.py and silently WRONG anywhere under
lib/ — it yields lib/, which holds no docs/ or data/ — and it fails by finding NOTHING
rather than by raising, so the seeds would just quietly never run.
lib/builtin_content.py (#900) closed that by taking the root as a parameter. This adds the
other half: server.py publishes it ONCE as appstate.server_root, so no module under lib/
ever has a reason to derive it. Documented at the slot.
pyflakes caught two more missing imports on the way in (loosefolder_mod, enrichment) —
each a NameError on a live scan path, and the suite would have handed them over one failure
at a time. It stays part of every server.py slice.
TESTS. The two scan fixtures (test_settings_api::scan_module,
test_feedpak_extension::scan_server) patched server._make_scan_executor to swap the spawn
pool for an in-process ThreadPool; they now patch it on lib/scan.py. Worth noting WHY that
still works: the fixtures re-import `server` per test, but `scan` stays cached in
sys.modules — and it picks up the fresh CONFIG_DIR anyway, because the appstate reads are
late-bound. The seam is doing exactly the job it was built for.
━━━ TEST ISOLATION: A REGRESSION THE CARVE ITSELF CREATED (Codex [P2]) ━━━
background_scan() deliberately NEVER sets running=False — ownership of that flag lives in
_scan_runner, so a kick_scan() racing the terminal write cannot observe a stale False and
start a second runner. Correct in production.
But the scan fixtures call background_scan() DIRECTLY, skipping the runner. That was
harmless while the state lived on `server`, which the fixtures RE-IMPORT per test. It is
NOT harmless now: `scan` stays cached in sys.modules across sys.modules.pop("server"), so
the status dict OUTLIVES the test. One direct call leaves the shared scanner marked
"running" forever, and every later scan or rescan returns "already in progress" and quietly
does nothing.
Verified: after a direct call, kick_scan() returns False and starts no scan at all.
The suite passed anyway, on ordering luck — which is exactly how this class of bug ships.
tests/conftest.py::reset_scan_state now snapshots and restores lib/scan.py's module state
around the two fixtures that drive it directly.
pytest 2398, pyflakes 0, Codex 0.
Refs #48
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
70dbe45e27
|
refactor(server): carve builtin-content seeding into lib/builtin_content.py (R3b) (#900)
lib/builtin_content.py (321 lines moved). server.py 2,418 -> 2,098.
The calibration/diagnostic sloppaks and the starter library: _copy_builtin_packs,
_write_builtin_pack, the two seed helpers, their source tables, and the seed marker.
━━━ THE ONE SIGNATURE CHANGE, AND WHY THE CARVE IS UNSAFE WITHOUT IT ━━━
server.py has:
def _feedBack_server_root() -> Path:
return Path(__file__).resolve().parent
That is correct IN server.py: the repo root in dev, resources/feedBack when bundled — the
tree that actually holds docs/ and data/.
Move that body into lib/ unchanged and it keeps working, silently, and returns lib/. There
is no docs/diagnostics under lib/, so every seed would find nothing, log "source missing"
at debug, and return. Nothing raises. Nothing fails. The starter library simply never
appears, and the calibration sloppak is never seeded — on a fresh install, in the field.
A verbatim move whose MEANING changed because __file__ did.
So this module cannot compute a root: `server_root` is a PARAMETER, and server.py — the
only place that legitimately knows where it lives — passes it in. The trap is now
structurally impossible rather than merely avoided. (_copy_builtin_packs already took the
root that way; the two seed helpers now do too.)
Everything else is byte-identical. CONFIG_DIR is read late as appstate.config_dir and the
DLC root through dlc_paths._get_dlc_dir — the same seam every router in lib/routers/ uses,
late-bound because tests monkeypatch it.
━━━ PYFLAKES FOUND THREE MISSING IMPORTS THE TESTS WOULD HAVE FOUND ONE AT A TIME ━━━
The moved code uses `secrets`, `stat` and `tempfile`; none was in my import block. Each is
a NameError on a live path. `python3 -m pyflakes` names all three in one shot — this is the
Python twin of the no-undef gate that guarded every frontend carve, and it should run on
every server.py slice from here.
It also flagged a PRE-EXISTING one I deliberately did not touch: server.py's
TuningProviderRegistry.get_merged() calls `logger.exception(...)` in an except handler and
there is no `logger` in the module (it is `log`). So a raising tuning provider takes down
the merged-tunings call for everyone, with a NameError naming the wrong problem. Filed as
issue #899 rather than smuggled into a carve whose whole value is being behaviour-neutral.
The constants lost their underscore prefix: they cross a module boundary now (the seed
tests read them), so `_BUILTIN_STARTER_SOURCES` was a lie.
pytest 2397, pyflakes 0, Codex 0. Guarded by the plugin_context contract test (#898).
Refs #48
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5e30138c87
|
refactor(server): extract the artist routes into routers/artist.py (R3) (#870)
The artist page + external-links payload (/api/artist/{name}/page, /links,
/links/refresh) plus their exclusive helpers (_artist_links_payload,
_artist_links_from_mb, the URL-slot table) move to lib/routers/artist.py. Bodies
verbatim except @app->@router and the seam reads (meta_db->appstate.meta_db,
CONFIG_DIR->appstate.config_dir, _default_settings->appstate.default_settings).
MB link enrichment is reached as enrichment.X; the URL-safety validator is
imported from lib/library_registry.py. No new seams.
server.py: 2,507 -> 2,413 (-94).
Verified: pyflakes clean; route set unchanged (143); full pytest 2395 passed.
eslint 0.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
0547f55844
|
refactor(server): extract the media/file-serving routes into routers/media.py (R3) (#869)
Song audio (/audio/{f}), the local-audio-path resolver (/api/audio-local-path),
and raw sloppak-member serving (/api/sloppak/{f}/file/{rel}) — plus the shared
_resolve_sloppak_local_file helper — move to lib/routers/media.py. Bodies verbatim
except @app->@router and the cache/static path seams (AUDIO_CACHE_DIR->
appstate.audio_cache_dir, STATIC_DIR->appstate.static_dir, SLOPPAK_CACHE_DIR->
appstate.sloppak_cache_dir — all already in the seam). No new slots.
The two test fixtures that redirect STATIC_DIR to a temp dir now also patch
appstate.static_dir (the moved routes read the seam, not server's global).
server.py: 2,638 -> 2,507 (-131).
Verified: pyflakes clean; route set identical (143), all unique-path (no
catch-all, no shadowing); full pytest 2395 passed (the audio-local-path +
sloppak-file-traversal cases). eslint 0. Boot smoke: /audio, /api/sloppak/{}/file
serve 404 for unknown, 0 tracebacks.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b7624b7e65
|
refactor(server): extract the enrichment route handlers into routers/enrichment.py (R3) (#868)
The 14 /api/enrichment/* routes — status, kick/cancel, per-song state, the Match-Review queue (accept/reject/pick/search/rematch/refresh/states), and the AcoustID fingerprint identify endpoints — plus the route-exclusive candidate sanitizer move to lib/routers/enrichment.py. Bodies verbatim except @app->@router and the seam reads (meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir). The enrichment engine (transport, matcher, worker, upload caps) already lives in lib/enrichment.py from the earlier subsystem move and is reached as enrichment.X. No new seams. server.py: 2,925 -> 2,638 (-287). Verified: pyflakes clean; route set identical (143); full pytest 2396 passed (the enrichment route + Match-Review + identify cases, which fake the network on the enrichment module). eslint 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f09c4a217f
|
refactor(server): extract library + collections routes + the provider registry (R3) (#866)
The library query surface (songs/albums/artists/stats/genres/tuning-names/ practice-suggestions), the provider list/art/sync endpoints, and collection CRUD move to lib/routers/library.py. The registry itself — LibraryProviderRegistry, LocalLibraryProvider, SmartCollectionProvider, the collection-provider lifecycle (_sync/_unregister_collection_provider), and the shared query/collection helpers (_library_filter_args, _split_csv, _sanitize_collection_rules, _safe_art_redirect_url, the filter-key sets) — moves to lib/library_registry.py. The PLUGIN CONTRACT is untouched: server.py still constructs the singleton (LocalLibraryProvider needs meta_db), still exposes register_library_provider / unregister_library_provider to plugins via plugin_context (with the per-plugin ownership scoping in plugins/__init__.py), and injects the registry + local provider into appstate. The router reads appstate.library_providers / appstate.local_library_provider at call time; the provider classes are duck-typed so no plugin imports a base class. Acyclic: library_registry imports routers.art (for LocalLibraryProvider.get_art) + appstate, never server. server.py: 3,692 -> 2,925 (-767). Verified: pyflakes clean; ORDERED route table identical set (library block mounts at one site — all exact/specific-path, no catch-all, no shadowing); full pytest 2397 passed (incl the plugin register/unregister + collection-as-provider tests). eslint 0. Boot smoke: /api/library + providers list "local"; a created collection surfaces as a `collection:N` provider through the seam; collection CRUD clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bbdff4e10f
|
refactor(server): extract the song routes into routers/song.py (R3) (#864)
Upload/delete, the catalog-metadata write-back, user-meta, overrides, gap-fill,
and the per-song info payload (11 routes) move to lib/routers/song.py with their
exclusive helpers (the atomic upload commit + the song-IO lock, the upload caps,
the gap-fill proposal builders). Bodies verbatim except @app->@router and the
seam reads: meta_db->appstate.meta_db, art_override_paths->appstate.art_override_paths,
and the scan/ingest helpers that stay in server.py (the scan lifecycle owns them)
-> new appstate seam callables: kick_scan, invalidate_song_caches, stat_for_cache,
and scan_status() (a getter — the underlying dict is reassigned). The gap-fill
MBID/ISRC regexes are reached as enrichment.X; _MULTIPART_OVERHEAD_SLACK (shared
with the staying AcoustID-identify route) moves to lib/enrichment.py beside
_ACOUSTID_MAX_UPLOAD_BYTES.
ROUTE ORDER: song_router mounts AFTER art_router — get_song_info's catch-all
`/api/song/{filename:path}` would otherwise shadow `/api/song/{path}/art*`
(Starlette matches first-registered; the :path converter is greedy).
server.py: 4,478 -> 3,692 (-786).
Verified: pyflakes clean; ORDERED route table preserves the specific-before-catch-all
invariant; full pytest 2397 passed (incl the art/cover 304 + CAA-fetch tests that
caught the shadowing before it was fixed). eslint 0.
BEHAVIORAL — needs an on-device pass (upload a sloppak, edit metadata write-back,
delete a song) before merge.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
7258e1066a
|
refactor(server): extract the settings routes into routers/settings.py (R3) (#863)
GET/POST /api/settings, /api/settings/reset, and the two-phase atomic export/import bundle (/api/settings/export|import) move to lib/routers/settings.py with their exclusive helpers (the relpath allowlist validator, the atomic writer, the library-DB snapshot + sqlite integrity gate, the config-type validator, the bundle schema). Bodies verbatim except @app->@router and the seam reads: meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir, _running_version->appstate.running_version(), and _default_settings-> appstate.default_settings (the canonical defaults builder stays in server.py — the scan + artist-links code share it — and is injected as a new seam callable). server.py: 5,539 -> 4,478 (-1,061). Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET); route table IDENTICAL (143); full pytest 2397 passed (154 settings cases incl the export→import round-trip + library-DB snapshot/restore + relpath-allowlist SSRF/ traversal guards, retargeted onto the settings module). eslint 0. BEHAVIORAL — needs an on-device settings export→import round-trip sign-off before merge (do not merge on green CI alone). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
73127d5416
|
refactor(server): extract the album-art routes into routers/art.py (R3) (#862)
The six song-art routes — GET /api/song/{f}/art, .../art/cover-search,
.../art/candidates, POST .../art/upload, .../art/url, DELETE /api/art/{f}/override
— plus their exclusive helpers (the ETag/304 response machinery, _save_art_override,
_url_host_is_internal, _fetch_art_url + the art size/redirect caps) move to
lib/routers/art.py. Bodies verbatim except @app->@router and the seam reads:
meta_db->appstate.meta_db, ART_CACHE_DIR->appstate.art_cache_dir, and the three
shared art helpers that stay in server.py (used by the song/delete routes too)
-> appstate.<callable> (_song_pack_art_exists, _art_override_paths — already
seam-injected for the enrichment worker — plus a new art_safe_name slot). The
CAA / release-search transport lives in lib/enrichment.py and is reached as
enrichment.X. LocalLibraryProvider.get_art now calls art_router.get_song_art.
server.py: 5,988 -> 5,540 (-448).
Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2399 passed (33 art serve/candidates/
override/url cases incl the SSRF-guard _url_host_is_internal + _fetch_art_url
size-cap tests, retargeted onto the art module); test_packaging 43; eslint 0.
Boot smoke: /art 404, /art/candidates 404, DELETE /override 200 from the router.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
165475d115
|
refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3) (#861)
* refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3)
MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and
the background enrichment worker (~930 lines, 61 defs) leave server.py as one
cohesive unit. Bodies are verbatim; the only changes are seam reads:
meta_db / config_dir / sloppak_cache_dir / art_cache_dir -> appstate.<slot>
_song_pack_art_exists / _art_override_paths (stay in server.py for the art +
delete routes) -> appstate.<callable> (new seam slots, injected by reference)
_env_flag -> env_compat.env_flag_compat (the existing identical helper)
_artist_title_from_filename -> imported from metadata_db (its home)
the User-Agent VERSION lookup: Path(__file__).parent ->
Path(__file__).resolve().parents[1] (lib/enrichment.py -> app root)
server.py drives the worker through the module (import enrichment; the routes +
scan lifecycle call enrichment.X). Tests that faked the network on `server`
(_mb_http_get, _enrich_network_enabled, _caa_http_get, ...) now patch the same
names on `enrichment` — the module attribute is resolved at call time, so one
setattr reaches both the routes and the worker's internal callers. Acyclic:
enrichment imports appstate/appconfig/dlc_paths/metadata_db/mb_match/
acoustid_match/sloppak/loosefolder, never server.
server.py: 6,917 -> 5,988 (-929).
Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2400 passed (140 enrichment/art cases
incl the offline-safety + transport-error-pauses-pass contracts that fake the
network); test_packaging 44 passed (enrichment.py resolves under lib/); eslint 0.
Boot smoke: /api/enrichment/status + POST /kick serve from the new module.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: reset enrichment worker state between tests (CodeRabbit)
lib/enrichment.py now owns the worker, and it stays imported for the whole
session while the `server` fixtures pop-and-reimport `server` — so the cancel
Event / status dict / caches would leak across tests, and a stale `_enrich_cancel`
could short-circuit a later direct `_background_enrich()`. An autouse conftest
fixture clears that process-global state before each test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: tighten the enrichment-reset fixture (CodeRabbit)
Narrow the import guard to ImportError (not blind Exception, BLE001), and stop
clearing _caa_index_locks — it's guarded by _caa_index_locks_guard, so an
unlocked clear() would race a still-alive worker, and its per-release mutexes
carry no test state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
508829c012
|
refactor(server): extract /api/tunings into routers/tunings.py + lib/appconfig.py (R3) (#858)
Some checks are pending
ship-ci / ci (push) Waiting to run
The merged-tuning-catalog route moves to lib/routers/tunings.py, verbatim except
@app->@router, CONFIG_DIR->appstate.config_dir, and the two seam substrates it
needed:
- lib/appconfig.py — the pure config.json reader `_load_config` (used by ~11
server sites + future config-reading routers). server.py re-imports it, so
those call sites and any `server._load_config` test reference are unchanged.
- appstate.tuning_providers — the TuningProviderRegistry instance injected by
reference (a stable object mutated in place via register()/unregister()), so
the router reads the same registry plugins populate through plugin_context.
The instance stays defined in server.py, so `server.tuning_providers` still
resolves — zero test retargets.
The tuning constants (DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS,
freqs_to_midis) already live in lib/tunings.py and are imported directly.
server.py: 6,960 -> 6,917.
Verified: pyflakes clean (bar the pre-existing unused `tuning_name` import);
route table IDENTICAL (143); full pytest 2400 passed (110 tuning/config cases);
eslint 0. Boot smoke: /api/tunings serves referencePitch + tunings + tuningMidis.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
cce95cbd1e
|
refactor(server): extract the diagnostics routes into routers/diagnostics.py (R3) (#857)
The three /api/diagnostics/* routes (export, preview, hardware) plus their exclusive payload-cap helpers and the `_diag_*` normalisers. Bodies verbatim except @app->@router, CONFIG_DIR->appstate.config_dir, _running_version()-> appstate.running_version() (a new seam slot; the impl stays in server.py where the settings region also calls it), and the builtin-plugins lookup in _diag_plugins_roots: Path(__file__).parent -> Path(__file__).resolve().parents[2] (routers -> lib -> app root; plugins/ ships at the app root in every packaging path). The pure caps/normalisers (_diag_cap_console/_dict/_contributions, _diag_coerce_bool, _diag_normalize_include, _DIAG_MAX_*) are re-exported from server.py so the existing `server._diag_*` / `server._DIAG_*` tests keep resolving — none of them monkeypatch these, so no test retargets. server.py: 7,216 -> 6,960. Verified: pyflakes clean (bar the intentional re-export lines); route table IDENTICAL (143); full pytest 2399 passed (77 diag/packaging + 122 diagnostic- matched cases incl the cap/coerce/normalize suites); eslint 0; Codex pending. Boot smoke: /hardware, /preview, and POST /export (200 application/zip) all serve from the new router location. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
32ebc7671e
|
refactor(server): extract the version route into routers/version.py (R3) (#856)
Some checks are pending
ship-ci / ci (push) Waiting to run
GET /api/version + its exclusive _safe_http_url URL validator. Bodies verbatim except @app -> @router and the VERSION-file lookup: Path(__file__).parent (the app root when this lived at the top level) -> Path(__file__).resolve().parents[2] (routers -> lib -> app root). VERSION ships at the app root in every packaging path (Dockerfile COPY VERSION /app/, desktop bundle). server.py: 7,275 -> 7,214. Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed (33 in test_version_endpoint, incl the URL-validation + env-override cases); eslint 0. Boot smoke: /api/version returns the real version + validated source/license URLs. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
46f3be7fd7
|
refactor(server): extract XP + per-song stats into routers/stats.py (R3) (#855)
The last of the progression cluster. XP award (1 route) + per-song practice stats
(record/recent/best/top/per-song, 5 routes) — meta_db-only apart from the two
seam accessors record_stats uses (get_progression_content, builtin_diagnostic_
filename, both landed by shop/progression). Bodies verbatim; @app -> @router,
meta_db -> appstate.meta_db, _as_int from metadata_db, _clean_str from reqfields.
Three scattered source blocks (xp, the stats block, and the separated
/api/stats/{filename:path} which the /api/library/practice-suggestions route
splits off) are assembled into one module and mounted once. Registration order
is preserved WHERE IT MATTERS: the /api/stats/{filename:path} catch-all is
assembled LAST inside the router, so it still can't shadow the fixed /recent
/best /top paths — verified against the live route table (recent/best/top all
precede the catch-all) and the route SET is identical to origin/main (143).
server.py: 7,478 -> 7,275.
Verified: pyflakes clean; route set identical + catch-all-last; pytest 2401
passed (113 across song_stats/profile/progression); eslint 0. Boot smoke:
/stats/recent /best /top all 200 (not shadowed), xp/award 200.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
4cc8fa3b4d
|
refactor(server): extract the profile routes into routers/profile.py (R3) (#854)
6 routes (get/set profile, bundled+custom avatars, avatar upload/serve, progress) + the exclusive _list_bundled_avatars helper. Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db, CONFIG_DIR/STATIC_DIR -> appstate.config_dir/ static_dir (seam), _clean_str from reqfields, _get_progression_content() -> appstate.get_progression_content(). No STATIC_DIR test retarget: _list_bundled_avatars reads appstate.static_dir but test_profile_api doesn't patch STATIC (only the sloppak/audio/traversal suites do, for handlers that stay in server.py). server.py: 7,594 -> 7,478. Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed (61 in test_profile_api); eslint 0. Boot smoke: GET /api/profile 200 (drives get_progression_content), /avatars lists via appstate.static_dir, /progress 200. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f9f33320ac
|
refactor(server): extract the progression routes into routers/progression.py (R3) (#853)
4 routes (overview/add-paths/onboarding/events, spec 010) + their EXCLUSIVE helpers (_goal_ui_progress, _progression_overview 112L) + the _PROGRESSION_EVENT_TYPES whitelist. Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db, _clean_str from reqfields. The two SHARED server accessors read through the seam: get_progression_content (added for shop #851) and builtin_diagnostic_filename (new slot — a trivial const-returning fn shared with the stats router's api_record_stats). Both are injected via the second appstate.configure() after their defs (the import-top configure runs before them). The cache + fns stay in server.py, so test_progression_api's server._progression_content patch is untouched — 0 retarget. server.py: 7,798 -> 7,594. Verified: pyflakes clean; route table IDENTICAL (143); both seam accessors wired; pytest 2401 passed (63 in test_progression_api); eslint 0. Boot smoke: GET /api/progression 200 (drives _progression_overview + both accessors), events 400 on bad body. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5f58af4faa
|
refactor(server): extract the shop routes + inject get_progression_content into the seam (R3) (#851)
The progression-content substrate: `_get_progression_content` (a lazy, double-checked-locking content cache) is now published into the appstate seam as a CALLABLE. The cache global + lock + the function stay in server.py (startup uses it, and test_progression_api patches `server._progression_content` directly), so ZERO test retargeting — routers just call `appstate.get_progression_content()`. Because the accessor is defined at server.py:1152 but the import-top configure() runs at :346, a second `appstate.configure(get_progression_content=...)` publishes it right after the def (configure is idempotent/additive). First consumer: routers/shop.py (3 routes: buy/equip/list). Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db, _clean_str from reqfields, _get_progression_content() -> appstate.get_progression_content(). This unblocks stats/progression/profile next (all share the accessor). server.py: 7,880 -> 7,845. Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed (test_progression_api's server._progression_content patch still works via the kept cache); packaging guard; eslint 0. Boot smoke: GET /api/shop 200 (drives appstate.get_progression_content), buy 400 on bad body. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ea8834862d
|
refactor(server): batch the small meta_db user-state endpoints into routers/library_extras.py (R3) (#850)
work keeper-chart prefs (3) + favorites toggle + tags list + saved toggle + session/continue — 7 routes across 5 domains, all meta_db-only (0 setattr, 0 helpers). Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db, _clean_str from reqfields. These were scattered singletons with no natural neighbor, so they're grouped as "small library/user-state endpoints" and mounted once. All paths are distinct and non-overlapping, so registering them together doesn't change routing: verified the route SET is identical to origin/main (143) AND that no moved path shadows or is shadowed by another (order-independence check). server.py: 7,880 -> 7,833. Verified: pyflakes clean; route set identical + order-independent; pytest 2401 passed; packaging guard 45; eslint 0. Boot smoke: tags/session GET 200, favorites/saved toggle (400 on missing filename), work/charts 200. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cbc65458e3
|
refactor(server): extract the wishlist routes into routers/wanted.py (R3) (#847)
Free after _clean_str moved to lib (#841): wanted's deps are JSONResponse, _clean_str (reqfields), app + meta_db (seam). 3 routes (list/add/remove), 0 setattr targets, 0 helpers to relocate. Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db. include_router at the original site; 143-route table identical. No test retargeting. server.py: 8,003 -> 7,974 (this branch is independent of the chart PR). Verified: pyflakes clean; route table identical; pytest 2401 passed (52 in test_wanted_api); eslint 0. Boot smoke: add wishlist entry -> list -> delete, 400 on missing artist+title (_clean_str path). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7c87538d6b
|
refactor(server): extract the chart routes into routers/chart.py (R3) (#846)
Unblocked by the DLC-path substrate (#843): chart's only server-module deps are now app + meta_db (both seam); _get_dlc_dir/_resolve_dlc_path come from dlc_paths, sloppak/loose detection from the shared lib modules. 4 routes (split/unsplit/ work/fileinfo), meta_db-only otherwise. 0 setattr targets, 0 helpers to relocate. Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db. include_router at the original site; 143-route table identical to origin/main. No test retargeting. server.py: 8,003 -> 7,909. Verified: pyflakes clean on the router; no new undefined/dead in server.py; route table identical; pytest 2401 passed (74 across work_charts/context_menu/ group_filter/packaging); eslint 0. Boot smoke: chart/work 200, chart/fileinfo resolves the real pack path through _resolve_dlc_path. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c8701991cb
|
fix(ws_highway): swallow a mid-stream WebSocketDisconnect quietly (#845)
* fix(ws_highway): swallow a mid-stream WebSocketDisconnect quietly Pre-existing (byte-identical to origin/main), flagged by CodeRabbit on #844: the outer try's only guard was `except Exception as e: log.exception("highway_ws unhandled error")`. The inner `except WebSocketDisconnect` covers ONLY the post-`ready` keep-alive loop, and the drum_tab/notation sends have their own localized guards — but the ~13 other streamed sends (beats, sections, notes, chords, anchors, …) fall through to the blanket handler. Since WebSocketDisconnect is an Exception subclass, a routine tab-close mid-load was logged as an error at whichever send was in flight. Fix: a dedicated `except WebSocketDisconnect: return` before the blanket handler, matching the two localized guards and the lib/ coding guideline. tests/test_ws_highway_disconnect.py drives highway_ws with a fake websocket that raises WebSocketDisconnect on the first streamed send (`loading`), over a real minimal sloppak. Negative-checked: removing the guard makes it fail (the disconnect is logged as `highway_ws unhandled error`); the fix passes. Uses a raw handler on `feedBack.server` rather than caplog, since configure_logging() reroutes that logger through structlog where caplog doesn't observe it. Full suite green. Closes the review thread on #844. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ws_highway): assert streaming stops after disconnect (exactly one send) — CodeRabbit The stub now raises only on the first send and the test asserts ws.sends == 1, so a handler that caught the disconnect and kept streaming would fail. Still negative-checked: removing the guard fails the test. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
514461167e
|
refactor(server): extract the highway WebSocket into routers/ws_highway.py (R3) (#844)
The single largest handler in server.py — the 902-line /ws/highway/{filename}
chart streamer — plus its 3 exclusive helpers (_pick_smart_arrangement,
_sanitize_authors, _sanitized_song_offset). server.py: 9,008 -> 8,003 (-1,005,
the biggest single R3 cut).
Clean move despite the size: the handler's only server-module deps are the 4
path constants + 3 exclusive helpers + app + log. Everything else it uses is
either NESTED inside the handler (_evict_audio_cache, _fill_scale_degree,
_manifest_entries, _tone_names, _xml_rank, _send_keepalives) or imported from
the shared lib modules (song/audio/sloppak/drums/notation/dlc_paths/metadata_db).
- Path constants read through the appstate seam: added static_dir /
sloppak_cache_dir / audio_cache_dir slots (config_dir already there);
server.py configures them. Bodies otherwise verbatim (@app.websocket ->
@router.websocket, PATHS -> appstate.*, log -> module logger).
- sloppak_cache_dir IS setattr-patched, so the 3 test_highway_ws_* suites now
also `setattr(appstate, "sloppak_cache_dir", ...)` next to their existing
server patch. _sanitize_authors unit tests import it from routers.ws_highway
(it moved). No other test churn.
- Removed 23 now-dead imports from server.py (song/audio/drums/notation/
bisect/contextvars/structlog/WebSocket*/_arr_smart_sort_key) — diffed against
the origin/main unused-import baseline so only NEWLY-dead ones went.
owns_tmp (assigned, never read) moved verbatim — it's pre-existing dead on
origin/main too; left as-is to keep the move faithful.
Verified: route table identical to origin/main (143, paths/methods/order);
handler body verbatim spot-checked; pyflakes clean (server has no new
undefined/dead); pytest 2400 passed; packaging guard 53; eslint 0. Boot smoke:
the highway WS streams the full chart (song_info/beats/sections/notes/chords/
notation/anchors/drum_tab -> ready) BYTE-for-byte the same message sequence as
origin/main across arrangements 0/1/2, zero tracebacks.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
0dcc9136b6
|
refactor(server): move DLC path resolution to lib/dlc_paths.py (R3 substrate) (#843)
The keystone for the path-dependent routers (ws_highway, song, audio-local-path, sloppak): the "where do the song files live + safe containment" layer leaves server.py so a router can reach it. - `_resolve_dlc_path` (pure — args only) moves verbatim. - `_get_dlc_dir` reads the env-derived paths through the appstate seam (appstate.dlc_dir/dlc_dir_env/config_dir) instead of module globals, so lib/dlc_paths.py does no import-time IO. - appstate gains `dlc_dir`/`dlc_dir_env` slots (config_dir already there); server.py configures them. All three are env-derived, so a setenv+reimport fixture reconfigures them for free — ZERO setattr retargeting. - server.py RE-EXPORTS both (`from dlc_paths import _get_dlc_dir, _resolve_dlc_path`), so its 24+16 call sites AND the tests that reach `server._get_dlc_dir()` / `server._resolve_dlc_path()` directly (test_dlc_junction, test_highway_ws_*) resolve unchanged — no test edits. server.py: 9,085 -> 9,008. Verified: _resolve_dlc_path byte-identical to origin/main; _get_dlc_dir's only change is the three path identifiers -> appstate.*; pyflakes clean; route table identical (143); pytest 2407 passed (test_dlc_junction + both highway_ws suites green via re-export); packaging guard 52; eslint 0. Boot smoke: library scans (8 songs, _get_dlc_dir), a real song resolves + serves art (_resolve_dlc_path), and the highway WS reaches `ready` with notes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6da01c55a4
|
fix(playlists): split cover decode (400) from persist (500), unique temp, existence re-check (#842)
Two pre-existing cover-upload issues CodeRabbit flagged on #841 (verbatim move, so correctly not fixed there): 1. One `except Exception as e` wrapped BOTH the PIL decode and the img.save/ tmp.replace, returned 400 for both, and echoed `e` — so a disk/permission failure was mislabeled a client error and could leak a filesystem path. Now: decode/validation -> 400 "Invalid image" (generic); save/replace failure -> logged 500 "could not save cover" (no detail). 2. A shared `{pid}.png.tmp` let two concurrent uploads clobber each other's temp file. Now a unique `tempfile.mkstemp` in the cover dir, atomic replace to publish. Plus an existence re-check just before publishing so an upload that raced a playlist delete can't leave an orphan cover. mkstemp itself is INSIDE the try (Codex catch): an unwritable dir / full disk raises there and is the same persistence failure as save/replace, so it hits the logged generic-500 path instead of escaping as an unhandled 500. Cleanup guards `tmp is not None` for the mkstemp-failed case. Did NOT add a full per-playlist critical section (CodeRabbit's "heavy lift"): FeedBack is single-user (Principle I), so a cover upload racing a delete on the same id can't happen — documented in the code rather than building a lock framework for a precluded race. tests/test_playlist_cover_errors.py pins all of it; negative-checked three ways: the old single-except-400 shape fails the 500 + no-leak-400 tests, and moving mkstemp back outside the try fails the temp-creation-500 test. Fix passes 5/5. Full suite 2405 passed; boot smoke: valid cover 200, bad image -> generic "Invalid image" 400, no .tmp litter. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a883f9213f
|
refactor(server): extract the playlists routes into routers/playlists.py (R3) (#841)
The biggest router yet — 12 playlist routes + custom covers — and the first that needs the config-path seam. server.py: 9,302 -> 9,085 (-217). Three causally-linked pieces, all required for playlists: - `config_dir` joins the appstate seam (the plan always put the path constants there; deferred in S3, needed now). It's env-derived, so the ~49 pop-and-reimport fixtures reconfigure it for free — ZERO setattr retargeting. STATIC_DIR/SLOPPAK_CACHE_DIR (patched via setattr) stay in server.py until a router that reads them is extracted, and get retargeted then. - `_clean_str` (pure request-field sanitizer, 14 callers) -> lib/reqfields.py; server.py imports it back. Unblocks wanted/saved/collections/profile/... later. - routers/playlists.py: bodies verbatim, `@app`->`@router`, `meta_db`-> `appstate.meta_db`, `CONFIG_DIR`->`appstate.config_dir`, `_clean_str` from reqfields, `_ART_CACHE_HEADERS` as a local const (art keeps server.py's). The two exclusive cover helpers (_playlist_cover_path/_url) move with it. include_router at the original site; full 143-route table identical to origin/main. One test retarget: test_playlists_api called `server._playlist_cover_path` directly -> now imports it from routers.playlists (reads appstate.config_dir, which the `server` fixture configures). Verified: pyflakes clean; route table identical; pytest 2401 passed (28 in playlists+collections+appstate); packaging guard 51 (auto-picked up reqfields); eslint 0; boot smoke drives create/rename/add-song/cover-upload/serve/delete — the cover writes 1.png under CONFIG_DIR THROUGH appstate.config_dir and serves 200 with an mtime cache-bust token; a wrong-typed name field still 400s via _clean_str; demo untouched. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4fd0cd49e7
|
fix(loops): take the DB lock for the count+insert and the list read (#840)
Two pre-existing races in the loops routes, flagged by CodeRabbit on #839 (the verbatim extraction moved them unchanged from server.py, so they correctly weren't fixed there): 1. save_loop computed `COUNT(*)` OUTSIDE meta_db._lock, then inserted inside it. Two simultaneous unnamed POSTs read the same count and both mint "Loop N". Fix: one lock scope around COUNT + INSERT. 2. list_loops read the shared single connection (check_same_thread=False) with no lock, so it could overlap a POST/DELETE commit. Fix: read under the lock, like every writer. Low severity in context — FeedBack is single-user (Principle I), so concurrent unnamed-loop POSTs essentially can't happen — but each fix is one lock scope. tests/test_loops_concurrency.py pins both with a threading.Barrier that releases 16 workers into save_loop at once. Negative-checked: reverting the COUNT back outside the lock fails the uniqueness assertion 5/5 runs; the fix passes 3/3. pytest 2400 passed; on-device two unnamed POSTs -> ['Loop 1','Loop 2']. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b41361eb1b
|
refactor(server): extract the loops routes into routers/loops.py (R3) (#839)
Third router. Practice loops (saved A/B regions per song): GET/POST/DELETE /api/loops, meta_db-only (0 setattr targets, 0 helpers to relocate per router_scan.py). Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db. include_router at the original site; 143-route table identical to origin/main. server.py: 9,337 -> 9,301. No test retargeting (test_demo_mode only names the paths in middleware regexes). Verified: pyflakes clean; route table identical; pytest 2398 passed; packaging guard green; eslint 0; boot smoke drives POST (auto-names "Loop N") / GET / DELETE / missing-fields error, and demo mode 403s both writes while allowing the read. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6c98aba433
|
refactor(server): extract the artist-alias routes into routers/artist_aliases.py (R3) (#838)
* refactor(server): extract the artist-alias routes into routers/artist_aliases.py (R3) Second router, picked by router_scan.py: the artist-aliases / Tidy-up (P4) group ranks at 0 monkeypatch.setattr targets and 0 helpers to relocate. 5 routes (list/set/merge/delete aliases + raw-artist picker), all meta_db-only. Bodies verbatim; only @app.<m> -> @router.<m> and meta_db -> appstate.meta_db. include_router mounts at the original site; full 143-route table identical to origin/main (paths, methods, order). No test retargets: test_artist_alias drives via TestClient(server.app) + server.meta_db, neither of which moved. Verified: pyflakes clean on the router; no new undefined in server.py; JSONResponse still used in server.py (not dead); pytest 2398 passed (18 in test_artist_alias); packaging guard green; eslint 0; boot smoke drives all 5 routes end-to-end (set ACDC->AC/DC, read back, 400 on missing fields, delete). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: credit both router extractions in the size-exemptions rationale (CodeRabbit) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b3215694e7
|
fix(build): move appstate.py + routers/ under lib/ so the desktop app ships them (#836)
The packaged desktop app died at startup:
File ".../Resources/slopsmith/server.py", line 71, in <module>
import appstate
ModuleNotFoundError: No module named 'appstate'
feedback-desktop's scripts/bundle-slopsmith.sh copies a HARDCODED list of core
files into the bundle -- server.py, VERSION, lib/, data/, static/,
plugins/__init__.py. The root-level appstate.py (#833) and routers/ (#834)
shipped fine in Docker, passed every test, and were silently dropped from the
packaged app.
Both now live under lib/, the one core directory every packaging path already
copies wholesale -- Dockerfile `COPY lib/`, docker-compose.yml, and the desktop
bundler's `cp -r lib` -- and that all three put on sys.path (on Windows via the
embeddable-Python ._pth, where PYTHONPATH is ignored: build-windows.sh writes
`../slopsmith` and `../slopsmith/lib`). No feedback-desktop change and no new
release are needed for this to take effect.
lib/ is also the CORRECT home under Principle V, and always was once the design
settled: with the injection seam, appstate.py constructs nothing and does no
import-time IO, and a route module only builds an APIRouter. The premise that
forced root placement -- "appstate opens sqlite at import" -- stopped being true
when configure() replaced ownership. The Dockerfile / .dockerignore /
docker-compose.yml entries added for the root layout are reverted; nothing else
in core changes (git mv, so --follow survives).
tests/test_packaging.py is the guard: it walks server.py's module-level imports,
keeps the ones resolving inside this repo, and fails if any lives outside a
directory the packagers copy -- with the ModuleNotFoundError spelled out. So the
next root-level core module can't ship broken. Negative-checked: restoring
appstate.py to the root fails it; the message names the file and the four
packaging files a root module would have to teach.
(It also has to skip `origin in {"built-in","frozen"}` -- on 3.14 the frozen
stdlib reports origin="frozen", and Path("frozen").resolve() lands inside the
repo, which flagged `os` and `stat` as first-party.)
Verified: the bundler's copy replicated exactly into a temp dir and booted with
PYTHONPATH=<bundle>:<bundle>/lib -- `import server`, `import appstate`,
`import routers.audio_effects` all resolve, appstate.meta_db is server.meta_db,
143 routes. The same simulation against origin/main reproduces the production
ModuleNotFoundError. pytest 2398 passed (2348 + 50 new); route table still
identical to origin/main (paths, methods, order); docker build context reaches
lib/appstate.py and lib/routers/ with no __pycache__; native uvicorn boot smoke
serves /api/version, /api/library, the moved audio-effects router, and all three
migrated plugins' src/ graphs; eslint 0 errors.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
94a58b7a42
|
refactor(server): extract AudioEffectsMappingDB into lib/audio_effects_db.py (R3) (#831)
Move-only, same shape as the MetadataDB extraction. The core-owned song/tone -> audio-effect-provider routing index leaves server.py for a flat lib/ module. The class body is byte-identical; server.py reconstructs exactly from origin/main minus the cut range plus the import-back and the call site. server.py: 9,705 -> 9,433 lines. The only non-verbatim change is the constructor seam: `__init__` takes `config_dir` instead of reading the module-level CONFIG_DIR, so the module does no IO at import (Principle V). The `audio_effect_mappings` singleton stays in server.py -- no route, no test, and none of the `monkeypatch.setattr(server, ...)` targets move. No import went dead. Verified: pyflakes clean on the new module; no new undefined name in server.py; pytest 2341 passed; eslint 0 errors; boot smoke drives the extracted DB end-to-end (POST a mapping -> GET reads it back -> audio_effects.db lands in CONFIG_DIR, proving the config_dir seam) and all three migrated plugins still serve their src/ module graphs. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
58120745bc
|
refactor(server): extract MetadataDB into lib/metadata_db.py (R3) (#830)
Move-only. The library metadata cache -- the `MetadataDB` class (4,018 lines) plus the query helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement naming, tag normalisation, the startup DB-restore swap) -- moves out of server.py into a flat `lib/` module. Every moved block is byte-identical to its server.py original; server.py is exactly origin/main minus the six cut ranges, minus the now-dead `import contextlib`, plus the import-back block and the constructor call site. server.py: 14,037 -> 9,705 lines. The one non-verbatim change is the seam that lets the class leave server.py: `MetadataDB.__init__` now takes `config_dir` explicitly instead of reading the module-level CONFIG_DIR, so `lib/metadata_db.py` does no IO at import (Principle V). The `meta_db` singleton stays in server.py, so `server.meta_db` (282 refs) and `server.app` (67 refs) resolve unchanged and no route moves. None of the 114 `monkeypatch.setattr(server, ...)` targets moved. Logging still goes through the `feedBack.server` logger, so log filters and caplog assertions resolve to the same logger object. `tests/test_settings_export_library_db.py` imports `_apply_pending_db_restore` from metadata_db (the test moves with its subject); no other test changed. Verified: pyflakes clean on the new module (zero undefined names, zero unused imports) and no new undefined name in server.py; pytest 2341 passed; node --test 1030 passed; eslint 0 errors; uvicorn boot smoke serves /api/version, /api/library, and all three migrated plugins' src/ module graphs (stems, studio, editor -> 200). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f1bae9774c
|
fix(gp2rs): write beat times at 6-decimal precision so imported tempo matches Guitar Pro (#819)
* fix(gp2rs): write beat times at 6-decimal precision The editor/timeline derives per-bar BPM from beat spans (bpm = beats*60/span), which amplifies rounding: at millisecond (3-decimal) precision a constant-tempo GP import (e.g. 140 BPM) shows a spurious per-bar "tempo drift" of ~0.05-0.7 BPM because most bar lengths don't land on a ms boundary (worse for fast/odd meters). gp2rs computes these beat times exactly from the GP tempo map, so the only precision loss is the ebeat/startBeat format string. Writing them at 6 decimals (microseconds) makes the derived tempo match GP's authored value. Verified on GP5 imports (Highway to Hell 116, Equivalence 140, Living After Midnight 138): the derived per-bar BPM collapses from two drifting values to the single authored constant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JgxKh99UAeQqmhzSc73tv Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * test(gp2rs): compare ebeat times by value, not string The 6-decimal beat-time write makes _assert_ebeats' exact-string compare fail ("0.500" vs "0.500000"). These tests only assert spacing, so parse both sides to float — precision-agnostic, no need to rewrite every parametrized list. 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> Co-authored-by: Byron Gamatos <xasiklas@gmail.com> |
||
|
|
751209b80e
|
Serve exact MIDI notes from GET /api/tunings (tuningMidis) (#829)
The tunings catalog is served as frequencies scaled to the reference pitch, so every consumer that needs note identities (the v3 instrument badge's TUNING_NOTE, plugins converging on the host profile model) reconstructs MIDI numbers client-side via log2 — a rounding footgun at non-440 references, and N copies of code the host can run once. Add `tuningMidis` to the response: the same catalog keyed instrument-count → name → absolute open-string MIDI notes (low → high). Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip at all); provider-contributed entries are inverted from their frequencies at the served reference via the new freqs_to_midis() (the inverse of open_midis_to_freqs, garbage-guarded). Purely additive — referencePitch/tunings are unchanged. Tests: every built-in round-trips at 440; round-trip holds at 430/432/444/450 (the exact case client-side reconstruction drifts on); garbage rejected. Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5cb4ea0623
|
feat(drums): capture velocities alongside times in unmapped-percussion reporting (#808)
* feat(drums): capture velocities alongside times in unmapped-percussion reporting Both drum converters opt-in out_unmapped capture (convert_drum_track_from_midi, convert_drum_track_to_drumtab) gain an index-aligned `velocities` list next to `times`, carrying each dropped note real dynamics — MIDI velocity verbatim; GP velocity with the same 1-127 gate as mapped hits, falling back to the 100 import default. A hand-mapping UI (the editor unmapped-notes dialog) can then restore mapped notes at their source dynamics instead of flattening to v:100 (editor-side consumer: feedBack-plugin-editor#111). The GP path chronological sort now reorders times and velocities in LOCKSTEP so multi-voice measures cannot silently reassign dynamics. Additive: callers that ignore the new key are unaffected. Tests: extended tests/test_midi_import_drums.py + tests/test_gp2rs_drums.py (alignment, lockstep sort, out-of-range fallback) — 26 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu * docs(gp2rs): clarify velocity-default comment, mark dead-path fallback - The mapped-GP velocity comment conflated GP's authoring default (95, Velocities.default) with the drumtab render default (100, DEFAULT_VELOCITY in lib/drums.py) used when `v` is omitted. Clarify both defaults and that only the latter applies to omitted hits. - Mark the `else: times.sort()` fallback in the unmapped-percussion time/velocity sort as belt-and-suspenders — times and velocities are always appended together under the same len<100 guard, so lengths can't actually diverge. No behavior change; comment-only maintainability nits from PR review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ChrisBeWithYou <chris@rifflarr.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> |
||
|
|
115c3529e9
|
fix(midi): guard non-positive division in the legacy inline tempo path (#805)
Some checks are pending
ship-ci / ci (push) Waiting to run
convert_midi_track_to_keys_wire builds its own inline tempo map and divides by the raw midi.ticks_per_beat at two sites (the tempo-table precompute and the tick_to_seconds closure). A malformed header with division == 0 raised ZeroDivisionError, and an SMPTE division (which mido returns as a NEGATIVE signed short) produced negative/garbage note times. Guard the divisor with `ticks_per_beat if ticks_per_beat > 0 else 480` so both the zero and negative cases fall back to the SMF default. The `> 0` form (not `or 480`) is required because a negative value is truthy and would slip past `or`. Positive-division behavior is unchanged. Follow-up to #796, which fixed the same class of bug in the newer convert_midi_tempo_map / _build_tick_to_seconds path. Adds two focused tests: division == 0 no longer crashes and emits a non-negative time, and a negative/SMPTE division yields sane non-negative times. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1bccb8a9e8
|
feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid (#796)
* feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid
The keys/drums MIDI converters always built a tempo-aware tick->seconds
map internally (to bake note times) and then discarded it — and never
read time_signature meta at all — so every MIDI import landed with no
bars, no measures, and an implied 4/4 no matter what the file said.
New lib/midi_import.py helper convert_midi_tempo_map(midi_path,
track_index) extracts the grid a .mid actually carries:
- tempos: {time, bpm} per tempo event (deduped per tick, 120 default)
- time_signatures: {time, ts: [num, den]} — the song-timeline shape
- beats: one row per beat on the editor grid shape — numbered downbeats
with a den hint, measure:-1 interior beats; the beat unit follows the
active signature (6/8 = six eighth-note rows per bar)
Event scope mirrors _build_tick_to_seconds: SMF type 0/1 merge meta
from all tracks, type 2 reads ONLY the chosen track (independent
timelines — callers must never share one grid across type-2 tracks).
Mid-bar signature events (ill-formed but seen in the wild) apply at the
next bar boundary. All times compute from absolute ticks through the
cumulative tempo table and round once at emit — rounding error never
accumulates with song length. A bar-count safety valve guards malformed
files. Consumer: the editor's multitrack MIDI import (tempo-seed
dialog, feedBack-plugin-editor roadmap Phase 3).
Tests: tests/test_midi_tempo_map.py — 10 cases driving the REAL
function against real .mid files built with mido (no stubs): default
grid, tempo bends, 500-bar rounding-drift check, 4/4->3/4 and 6/8
signatures, mid-bar signature deferral, duplicate-tick last-wins,
type-2 meta isolation from a bogus sibling track, empty files, grid
coverage bounds. Full MIDI-adjacent suite green: 55 passed
(test_midi_tempo_map + test_midi_import + test_midi_import_drums +
test_gp2midi) under the project venv.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu
* fix(midi): make convert_midi_tempo_map robust — real division guard, symmetric tempo default, single-pass meta
- Push the ticks-per-beat fallback into _build_tick_to_seconds (the single
place ticks route through), guarding on `> 0` so a division==0 (malformed)
or negative SMPTE-division header no longer raises ZeroDivisionError or
walks the beat grid into negative times. Mirror the guard at the
convert_midi_tempo_map beat_ticks site. The local `or 480` was cosmetic
before — the closure still divided by the raw division.
- Seed tempos_out with a 120 BPM row at time 0 when the first set_tempo
lands after tick 0, symmetric with the (0, 4, 4) time-signature default,
so the sidecar matches the grid the head of the song actually used.
- Collapse the duplicated meta_source/note_source lists into one
source_tracks walked in a single pass (meta collection + end_tick).
- Fix a weak assert in test_mid_bar_signature_applies_at_the_next_boundary
(operator-precedence `(A and B) or C`) to assert den == 4 outright.
- Add tests: non-positive division (0 + negative SMPTE), first tempo after
start seeds 120@0, explicit SMF type-0 file, and the _TEMPO_MAP_MAX_BARS
safety valve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
|
||
|
|
7cbf9824b1 |
Drop dead whisperx entry from allowed lyrics_source set
The whisperx->transcribed alias runs before the membership check, so the literal whisperx never reaches _ALLOWED_LYRICS_SOURCES (same reason sng is omitted). Remove the dead entry. Per CodeRabbit review on #799. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
33146cc7f6 |
Accept spec lyrics_source values (authored, transcribed)
The feedpak spec (§7.1) defines the lyrics_source vocabulary as {authored, transcribed, user}, but the reader only accepted the legacy {xml, notechart, whisperx, user} set and silently downgraded anything else to "xml". A spec-compliant writer (e.g. the stem_splitter plugin, which emits transcribed for WhisperX-produced lyrics) therefore lost its provenance badge.
Widen the allowed set to the union of the spec vocabulary and the legacy values so both validate, and alias the legacy whisperx engine name to the spec transcribed so existing packs normalise to the spec badge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
1a8540935b
|
fix(gp_autosync): slope-constrained DTW steps — stop path collapse on riff-based songs (#791)
librosa.sequence.dtw's default step sizes permit pure horizontal/vertical moves; on songs whose chroma is self-similar for long stretches the flat cost surface let the path collapse (minutes of score onto one audio frame), so auto-sync produced monotonic-but-garbage sync points and the per-bar warp imported charts badly out of sync while reporting success. Use the standard music-sync step pattern [[1,1],[1,2],[2,1]] (local tempo ratio bounded to 0.5x-2x), falling back to unconstrained steps if the global length ratio makes it infeasible. Validated on the reported song (138 BPM tab, YouTube audio): coarse points now track 1:1, refine holds slopes 0.77-1.04, warped downbeats hit onset peaks at 3.3x background energy. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
92dc321fdf
|
feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass (#787)
* feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass auto_sync computes per-bar sync points but consumers only ever applied the scalar bar-1 audio_offset, so any tempo drift between the recording and the tab's authored tempo accumulated over the song. Add the librosa-free helpers needed to apply the full piecewise mapping: - bar_start_times(gp_path): per-bar score times sharing auto_sync's axis (GPIF bar-resolution map, GP3/4/5 per-tick integration) - build_warp_anchors(points, bar_starts): monotonic (score, audio) anchors - warp_time(t, anchors): piecewise-linear map with edge-slope extrapolation - warp_song_times(song, warp): retime a lib.song.Song in place (notes, sustains, chords, beats, sections, anchors, handshapes, phrase levels, tone changes, tempo overrides) - gp_has_expandable_repeats(gp_path): detects GP3/4/5 repeat/volta/direction markup whose playback expansion auto_sync's as-written points cannot map Also implement refine_sync() — the editor's refine-sync endpoint has imported it since the snapshot but it never existed in lib, so the Refine button 500'd. It densifies the DTW points to every Nth bar and re-times each with a local onset phase sweep (radius clamped under half a beat to avoid one-beat locks, short scoring grid + median residual snap against the first beats). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117-123 BPM recordings of a 120 BPM tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review: Copilot round 1 — normalize bar_start_times GP3/4/5 parse failures to ValueError, document ImportError Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.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>
|
||
|
|
bde25c0bc8
|
fix(gpx): clamp partial final BCFS sector so GP6 .gpx import works (#749)
Every real Guitar Pro 6 (.gpx) file failed to import with "GPX BCFS sector pointer out of range (malformed file)". A real .gpx's BCFZ-declared decompressed size isn't 0x1000-aligned, so its last (small) container file lands in a partial trailing sector. _parse_bcfs raised whenever a sector read would run past the buffer end, rejecting the whole container before score.gpif could be extracted -- so no GP6 file could be charted in the song editor. (GP7/GP8 .gp files take the ZIP path, not BCFS, which is why this wasn't caught earlier.) Clamp the final sector read to the buffer end (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro). A sector whose start is past the end still raises, so the malformed-file guard is preserved. Verified against two real GP6 files -- both now unpack to valid GPIF with all tracks. Adds the previously-missing positive BCFS round-trip coverage: partial-final-sector, multi-file, sector-aligned baseline, and the preserved out-of-range guard. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.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> |
||
|
|
286c59707b
|
fix(tests): isolate plugin routes modules + redact .feedpak filenames (#736)
Two pre-existing failures the segfault had been masking (the run aborted at ~25%, so they never ran until #735 let the suite complete): 1) Tuner group (~24): plugins ship a bare-named routes.py, so sys.modules['routes'] leaked between plugin test dirs (achievements ran first, tuner got its module). Each plugin conftest now pops the stale 'routes' and an autouse fixture binds sys.modules['routes'] to that plugin's module for the duration of its tests (covers runtime 'import routes' in test bodies). 2) Diagnostics group (5): _SONG_FILENAME_RE never matched the tests' .feedpak/.archive filenames — it also lacked 'feedpak' (the current primary format), a real redaction gap. Added feedpak to the regex and switched the tests off the fake .archive to the real .feedpak. Verified: full suite 2183 passed, 0 failed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.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>
|
||
|
|
7ef52cdd66
|
fix: Edit Metadata persists into .feedpak files (suffix gate predated the rename) (#725)
write_song_metadata dispatched zip-form packages on `suffix == ".sloppak"` only, while core reads both suffixes everywhere else (sloppak.SONG_EXTS, .feedpak being the current write extension). Editing a zip-form .feedpak's title/artist/album/year therefore silently fell back to a DB-only update, which looked fine until the next full library rescan re-derived metadata from the file and reverted the edit — the exact failure this module exists to prevent. Directory-form packages were unaffected (manifest-presence dispatch, not suffix). Gate on SONG_EXTS, add TestWriteSongMetadata regression coverage (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback), and correct the stale scan_worker comment claiming .sloppak-suffix-only detection (the code already accepts both via is_sloppak). Claude-Session: https://claude.ai/code/session_01H1ZBEcZoJinde9ms5fAjwc Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |