diff --git a/CHANGELOG.md b/CHANGELOG.md index 224037e..3769d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,525 +1,542 @@ -# 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 -- **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. - -### 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. -- **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 -- **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 `