# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song carries several drum charts, a **Drum part** selector appears beside the arrangement switcher (advanced settings) so a player can choose which drummer to play. Selecting one re-streams that part's tab over the highway WS (`?drum_part=`, mirroring the arrangement switch); the choice persists across an arrangement change, and the picker reflects the server's authoritative part (unknown/absent selection falls back to the primary). The row hides for single-drum and non-drum songs, so nothing changes there. Builds on the loader below; no plugin change needed — the drum renderer just draws whatever tab streams. - **Multiple drum parts (feedpak 1.17.0 "drums as arrangements").** The sloppak loader now reads `type: drums` arrangement entries carrying per-arrangement `drum_tab` file pointers — a song can ship several drum charts (a second drummer, an aux-percussion layer). Parts surface as `LoadedSloppak.drum_parts` (primary first; the entry aliasing the song-level `drum_tab:` key is the primary and is never loaded twice), the highway WS `song_info` gains a `drum_parts` name list, and `?drum_part=` on the WS URL selects which part's tab streams (`drum_tab` messages carry `part_id` when multiple parts exist; unknown ids fall back to the primary). Pointer entries are **never** loaded as fretted arrangements — the loader's file/notation gate keeps a drum part out of the fretted pipeline (and out of note-detection grading), pinned by test. Legacy single-drum packs read exactly as before, as a one-part list. - **`chart-transform` capability domain (#952)** — plugins can now remap the chart before rendering and scoring through a core-owned provider coordinator. Synchronous transforms run after difficulty filtering; host data is isolated from providers, accepted timelines are time-sorted, and failures fall back to the original chart with a fixed public reason. Effective chart arrays and metadata are available to 2D/custom renderers and highway getters, while `getSongInfo()` retains the original metadata. Provider selection persists and applies to primary and splitscreen highways. - **Library filter: one-click "Not split" + a piano stem pill.** The v3 Filters drawer's stems section gains a **Not split** shortcut that selects "lacks every instrument stem" in one tap — the same query Stem Splitter's missing-stems view runs — instead of cycling five pills to ✕ by hand. The pill row also gains `piano` (the drawer offered five of the canonical six stems, so a piano-only song wrongly matched a hand-built "lacks all" filter). "No lyrics" already existed in the Lyrics section. - **Gold tier (career passports)** — an earned badge turns **gold** when Virtuoso verifies an improvised jam in the passport's style (the `gold_improv` artifact relays with the drill snapshot; a genre inherits its family's style, gained-only, and gold never substitutes for the badge bar itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on the shelf cover, profile wall, and passport card; the bronze page's "Gold rung coming" preview becomes a live invitation to jam it. - **Gigs (the career verb, frontend)** — book a gig from any opened passport: a gig poster proposes the setlist (re-roll for a different bill; save or copy the poster as a PNG), "Play the gig" hands the set to the play queue with the venue on stage, a floating strip tracks the set, and finishing it logs dated entries with per-song accuracies in the passport book — with an encore celebration (crowd eruption + confetti) when the whole set clears the bar, and a summary poster to share. Quitting mid-set simply abandons it: no log, no fail state. - **Career on the Profile and Home pages** — the Profile gains a passport wall (earned-badge covers per instrument, hours, gig count; absent until a passport exists), injected through the same mount-point + rendered-event seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md). The home page's plugin-count stat tile becomes a career trading card (badges, hours, the closest stamp ask, foil shine) with the old stat as the built-in fallback when career has no state. Earned passports gain **Save card / Copy card** — a natively-drawn PNG passport card, downloadable or copied straight to the clipboard for pasting outside the app (shared `blob-io` helpers replace the download idiom previously duplicated in settings-io and diagnostics-export). - **Gigs (backend)** — career mode gains its verb: `POST /api/plugins/career/gigs/propose` builds a playable setlist for an instrument+genre (your qualifying songs plus a couple of stakes songs near the bar; a young passport fills from unplayed genre songs — the first gig is how stubs start; re-roll by calling again), naming the room your stars can book. `POST /gigs` logs a **completed** set — per-song accuracies read from the set's own freshly-recorded stats, an encore flag at the data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never log (no fail state: the gig you finished is the gig you played). Passports carry their gig log; instruments their gig count. - **3D Highway: fret wires flash on a confirmed hit** — when a scorer (note_detect) confirms a note through the `getNoteState` provider (feedBack#254), the fret wires bracketing it light up. A fretted note lights the wire behind it and the wire it's pressed against; a chord lights only the outermost wires of its shape; an open string lights the anchor lane's edge wires (its gem is drawn as a slab spanning the lane, so those are the wires it sits between); a chord likewise lights the lane's edge wires — the lit lane strip can run a fret past the chord's outermost fret, and a bracket one wire inside the lit lane reads as misaligned (the shape's own outer pair survives only as the fallback on anchor-less charts). At most **two wires are ever lit at once**: when overlapping decay tails (fast passages) would light a run of wires, the flash collapses to the outermost pair of the lit span — one bracket, never a picket fence. The gem's rim joins in: on a confirmed hit the outline flashes in the string's own colour with the same intensity treatment as the wires, fading with the scorer's alpha. With no scorer attached, nothing changes. - **Background controls in the player (3D Highway)** — change the highway background mid-song from the player's Plugin Controls popover instead of opening Settings: a style dropdown, a Reactive toggle, and an Intensity slider, all kept in sync with the Settings page. Controls that the active style ignores are greyed out with a reason on hover (Custom video and Butterchurn use neither; Custom image uses Intensity but not Reactive), so a knob is never present-but-inert. The control disappears when a non-3D renderer is selected. The whole group also greys out while the Venue scene override is active, since none of the three controls reach a mounted style in that mode. ### Changed - **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS `ready` sends. The stems plugin could only learn it from that WS message, which arrives once the highway is already on screen — so it decoded and then copied the whole song's PCM to its audio worklet with the player visible: over half a gigabyte of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the song-credits card appeared. With the list available at `song:loading` the plugin does all of it before the highway is drawn. Built by calling `load_song` itself, so it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay nothing. - **Folder library renders only the songs on screen** (#965) — a song list used to render *every* song it held. On a flat 50,944-song library that was one `
` with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory), built even while another screen was showing. A document that size also punishes unrelated code: any `document.querySelector` that misses has to walk the whole tree — which is how the song-preview menu check ended up eating ~50% of the renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged. - **3D Highway: fret wires read as a focus cue** — the contrast between the active anchor lane and the rest of the neck is widened (the lane's wires brighter, the rest dimmer), and the wires themselves are slightly thicker. - **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a top-level manifest key this repo invented (#583) that the feedpak spec never had. The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0 (feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it from now. `full` is a mixdown, not a layer — it already contains every instrument — so `load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`. Nothing that sums stems or renders one fader per stem can see it, which is what makes retaining it safe; leaving it in the list would double the whole song and leave "guitar" audible with the guitar fader muted. That trap is exactly why the packer invented the key instead of putting the mixdown where the format says it goes — the bug was in the reader, and this fixes the reader. Consequences worth knowing: - The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`. `original_audio_url` / `has_original_audio` remain as **deprecated aliases** (same values) for one release so a client built against the old frame keeps working; they go with the fallback below (#945). - `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now describe *instrument* stems only — a separated pack that retains its mixdown no longer advertises a bogus "full" stem chip or an inflated stem count. - Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same way, which **widens** its coverage: it previously returned `None` for any pack without the invented key, so fingerprinting silently did nothing for the overwhelming majority of packs. - Core still **reads** `original_audio:` as a deprecated fallback, because every pack written before the spec caught up carries it and would otherwise lose its pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem at `default: off`, drops the key); the fallback and the aliases are removed once they are migrated (#945). ### Added - **Genres fall back to MusicBrainz enrichment** — the effective genre now resolves override → pack genre → the enrichment match's primary genre (matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key, which starved the library genre facet and career passports on real libraries; with the fallback, every enriched song's genre is browsable and passport-able immediately, and coverage grows as enrichment runs. - **Badge ceremony in the venue** — earning a genre badge now stages a moment: the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger; a no-op without a venue pack) and a full-screen overlay drops the bronze stamp with a shine sweep and a confetti burst over whatever screen is active (badges land right after `stats:recorded`, while the player is still up). Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing chime + notification only. The stamp still slams into the passport book on next open, unchanged. - **Hours-per-genre odometer (career passports)** — the app now measures real play time: the stats recorder accrues **wall-clock** seconds across play/resume ↔ pause/stop/end spans (wall time, not song position — position deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h against suspend/sleep inflation) and piggybacks them as `seconds` on the `POST /api/stats` calls it already makes. New additive `song_stats.seconds_total` column; a seconds-only POST banks time for unscored plays that run to the song's natural end without touching the resume position (and still counts as playing today for the streak). Passports surface it honestly: "14.2 h in Blues" under the badge and on the shelf cover — a true fact that only grows, never a target or a meter. - **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz now also asks for that genre's signature Virtuoso drill (Blues Shuffle, Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one per genre, data-driven in `passports.json` with display labels). Drill lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat list still means guitar), so a keys passport never demands a guitar drill. A drill counts as cleared on the first real completion artifact — a top-tier clean pass in one key (`keysCleared`), any depth rung, or mastery — rather than only the maxed-speed depth flips. Genres without a curated drill stay songs-only. - **Career passport visuals pack** — earned covers and badge stamps become trading cards (pointer-tracked tilt + light glint, hover-capable devices only); the ghost stamp visibly "carves in" as qualifying songs land (a conic ink fill, no numbers added); the Gold rung preview is a small foil chip with a shimmer sweep, still honestly labeled coming. All theatrics disabled under `prefers-reduced-motion`. - **Career passports (backend)** — the badge-journey layer on top of career stars. New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument passport walls: genre badges computed on read from `song_stats` × the library's effective genre — Bronze = N genre songs at K★, data-driven in `plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song "ticket stubs", the library genre list, and drill status), `POST /passports/commit` (instrument commitment), `POST /passports/open` (open a genre passport), and `POST /drill-state` (intake for the relayed Virtuoso `virtuoso.progress` snapshot, so drill requirements can gate badges server-side). Badges are never stored; the only persisted state (commitments, opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the settings export/import bundle via `settings.server_files`. Instruments are attributed via the existing progression arrangement→instrument mapping; non-graded instruments (bass, drums) render shown-not-judged — repertoire without a pass bar, never a false badge denial. - **Career passports (UI)** — the Career screen gains a Passports tab beside Venues: a physical per-instrument passport book (embossed leather cover, 3D page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge slam with ink bleed and deterministic per-genre jitter, qualifying songs as collected ticket stubs, and unopened genres as an "Explore next" travel-brochure rack (invitations, never greyed-out slots or completion meters). Badge earns chime + notify immediately; the stamp slam plays when the passport is next opened. Four small synthesized sound effects ship as plugin assets. The career screen also relays the Virtuoso `virtuoso.progress` localStorage snapshot to the drill-state intake on `virtuoso:progress` bus events (debounced, plus a one-time bootstrap), closing the fires-into-a-void seam without touching the virtuoso plugin. - **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing stopped core from reading a manifest key the spec never defined, which is exactly what happened with `original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's `manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and `lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including `setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows; (3) **forward** — core's `load_song()` ingests every example pack the spec ships; (4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today). The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible. **There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**) diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink. `original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the *next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs: [docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md). ### Removed - **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the `/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve `static/v3/index.html`, which has been the default since 0.3.0. This is the first step of the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so every subsequent step of that migration would otherwise have to be made, and verified, twice. Removing the fallback now halves that surface before any of it is touched. Incidentally fixes a latent bug in the old `index()` route — its guard read `if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`, whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0: Principle II's frontend file list now names `static/v3/index.html`. **Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there is no longer a classic shell to fall back to — unset the variable and use `/`. The env var itself is no longer read; the `SLOPSMITH_*`→`FEEDBACK_*` compat shim is unaffected. No chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). ### Fixed - **Count-in follows the song's meter and its pickup measure.** The count-in (loop wrap, section practice, and the "Countdown before song" setting) always clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song opening with a pickup (anacrusis) had the pickup enter where the downbeat belonged — putting the player a beat ahead for the whole song. The bar length now comes from the `song_timeline` beats already on the highway (`measure >= 0` marks downbeats; no new plumbing, since the `time_signatures` map is streamed to plugins rather than stored in the frontend), and a first bar shorter than that meter shortens the count by its length: a 1-beat pickup in 4/4 counts "1 2 3" and the music enters on 4. Songs without beats — pre-chart, minigames, synthetic highways — still get four. - **GP8 asset resolution honours the directory the registry named.** `` is matched on filename stem so a format variant of the same recording can win (an `.ogg` beside the declared `.mp3` is copied out losslessly instead of transcoded) — but the search was not restricted to the declared directory, so an unrelated file elsewhere in the archive that merely shared the stem could stand in for the declared asset. That is the exact substitution the registry lookup exists to prevent. Candidates are now confined to the registry path's own directory; a genuinely absent asset falls through as documented. - **Guitar Pro 8: the right backing track is extracted when a file carries more than one.** `BackingTrack/AssetId` is a key into the GPIF's `` registry — `` names the exact path inside the archive — but it was being matched against embedded *filename stems*. GP8 names embedded audio by hash while ids are small integers, so that match essentially never hit: every such file warned and fell through to "first audio asset". That was silently correct while a file carried exactly one recording — with two, a backing track declaring id 1 resolved to asset 0, i.e. the wrong take. Resolution now reads the registry first (verifying the path is really in the archive, so a stale entry falls through rather than resolving to nothing), then the legacy stem match, then the first asset. - **Guitar Pro import no longer fails on non-ASCII song metadata (Windows).** The GP→arrangement-XML writers wrote their output with `Path.write_text()` and no explicit encoding, so on Windows (cp1252 default) a metadata character like the © in an album name ("Chrysalis©1982") was written as a lone `0xA9` byte — invalid UTF-8 — and import died with `not well-formed (invalid token): line N, column 22`. All three arrangement XML writes now pin `encoding="utf-8"`. - **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the hit line toward the player. Nothing is ever drawn in that strip (notes and chord frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it. The floor geometry now ends at the hit line; its far edge is unchanged, still `-AHEAD*TS` at the note horizon. - **Career passports review polish** — the passport tabs and book overlay carry proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`; `role="dialog"` + `aria-modal` with focus moved to the close button on open and restored on close), and a corrupt stored seen-badges value (e.g. a stray `"null"`) can no longer throw on every passport refresh. - **The packaged desktop app could not start (`ModuleNotFoundError: No module named 'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`, `static/`, `plugins/__init__.py`. The root-level `appstate.py` and `routers/` added in R3 shipped correctly in Docker and passed every test, and were then silently dropped from the packaged app, which died at startup. Both now live under **`lib/`** — the one core directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop bundler (`cp -r lib`) all copy wholesale, and that all three put on `sys.path` (on Windows via the embeddable-Python `._pth`, where `PYTHONPATH` is ignored). This needs no change in feedback-desktop and no new release to take effect. Placing them there is also correct under Principle V: with the injection seam, `appstate.py` constructs nothing and does no import-time IO, and a route module only builds an `APIRouter`. The `Dockerfile` / `.dockerignore` / `docker-compose.yml` entries added for the root layout are reverted. New `tests/test_packaging.py` walks `server.py`'s module-level imports and fails if any first-party module resolves outside a directory the packagers copy, so the next root-level module can't ship broken. ### Added - **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. - **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a `fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file where they used to be defined** — FastAPI matches routes in registration order, so the mount site preserves it. Verified: the full 143-route table (paths, methods, *and* order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are the decorator receiver (`@app.get` → `@router.get`) and the singleton read (`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute resolved at call time). This proves the seam from #833 under a real consumer, including the second slot. The `_demo_mode_guard` middleware still blocks all four moved write routes with 403, and `Query(...)` validation still 422s — both checked against a running server. `server.py`: **9,445 → 9,386 lines**. - **`lib/enrichment.py` — the metadata-enrichment subsystem leaves `server.py` (R3, move-only).** MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and the background enrichment worker (~930 lines, 61 defs) move out as one cohesive unit. Bodies are verbatim; the only changes are seam reads — `meta_db`/`config_dir`/`sloppak_cache_dir`/`art_cache_dir` and the two shared art helpers (`song_pack_art_exists`, `art_override_paths`, which stay in `server.py` for the art/delete routes) are reached through `appstate` at call time, and the User-Agent VERSION lookup is corrected for the module's new depth. `server.py` drives the worker through the module (`import enrichment`; the routes + scan lifecycle call `enrichment.X`); tests that faked the network on `server` now patch the same names on `enrichment` (module attribute resolved at call time, so one `setattr` reaches both the routes and the worker's internal callers). Acyclic — `enrichment` imports no `server`. Route table byte-identical; full suite green. `server.py`: 6,917 → 5,988. - **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py` need `meta_db` and friends but must not `import server`, or the import graph goes circular the moment `server` imports them back. So `server.py` keeps *constructing* its singletons and now **injects** them once — `appstate.configure(meta_db=…, audio_effect_mappings=…)` — and a router reads them back as module attributes at call time (`import appstate; appstate.meta_db.…`). This is the Python analogue of the frontend refactor's injected `configureX({…})` seams and of the plugin `setup(app, context)` contract: dependencies flow one way, `server → routers → appstate`. Two properties are load-bearing and pinned by `tests/test_appstate.py`: (1) `import appstate` constructs nothing and touches no disk, so the ~49 test fixtures that `sys.modules.pop("server")` + re-import (to rebuild `meta_db` under a patched `CONFIG_DIR`) keep working untouched — a singleton *owned* by `appstate` would survive that pop and go stale; (2) reads must be late-bound (`appstate.meta_db`, never `from appstate import meta_db`), since a `from` import freezes the binding and defeats both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap as ES `import`. `configure()` rejects an unknown slot rather than silently creating a global nothing reads, and the suite asserts `server` actually calls it (a seam whose wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`. ### Changed - **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py` (R3, move-only).** The core-owned song/tone → provider routing index follows `MetadataDB` out of the host file, byte-identical apart from the same constructor seam (`__init__` takes `config_dir`; `audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)`), so the module does no IO at import. The singleton stays in `server.py`; no route, no test and no `monkeypatch.setattr(server, …)` target moves. `server.py`: **9,705 → 9,433 lines**. - **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, 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) — now lives in its own flat `lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the `meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before and every route is untouched. The only non-verbatim change is the seam that lets the class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly (`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`, which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging still goes through the `feedBack.server` logger, so existing log filters and `caplog` assertions resolve to the same logger object. `tests/test_settings_export_library_db.py` now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its subject); no other test changed. Every moved block is byte-identical to its `server.py` original. ### Added - **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `