mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 07:48:32 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c27ca7b3f | ||
|
|
ebe59d3f97 | ||
|
|
d6f2df14f7 | ||
|
|
94a58b7a42 | ||
|
|
58120745bc |
@@ -7,7 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **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
|
||||||
|
- **`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**.
|
||||||
|
- **`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
|
### 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).**
|
- **`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
|
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
|
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ without a *signed* exemption" is unenforceable.
|
|||||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||||
|
|
||||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||||
(9,705 — was 14,037; ratcheted by the R3 `MetadataDB` extraction) ·
|
(9,386 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||||
|
extractions and the first `routers/` module) ·
|
||||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||||
and is a monolith in its own right, to be split per-table once the router train
|
and is a monolith in its own right, to be split per-table once the router train
|
||||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Shared application state — the seam that lets route modules reach core
|
||||||
|
singletons without importing ``server``.
|
||||||
|
|
||||||
|
``server.py`` is the host: it owns the FastAPI ``app``, constructs the DB
|
||||||
|
singletons, and runs the lifecycle. As routes move out into ``routers/`` (R3),
|
||||||
|
those modules need ``meta_db`` and friends — but they must not ``import
|
||||||
|
server``, or the import graph goes circular the moment ``server`` imports them
|
||||||
|
back.
|
||||||
|
|
||||||
|
So ``server`` **injects** its singletons here once, at the point it builds them::
|
||||||
|
|
||||||
|
# server.py
|
||||||
|
meta_db = MetadataDB(CONFIG_DIR)
|
||||||
|
appstate.configure(meta_db=meta_db, ...)
|
||||||
|
|
||||||
|
and a router reads them back as **module attributes, at call time**::
|
||||||
|
|
||||||
|
# routers/artists.py
|
||||||
|
import appstate
|
||||||
|
|
||||||
|
@router.get("/api/artist/{name}/page")
|
||||||
|
def artist_page(name):
|
||||||
|
return appstate.meta_db.artist_page(name)
|
||||||
|
|
||||||
|
This is the Python analogue of the injected `configureX({...})` seams the
|
||||||
|
frontend refactor uses (stems' ``configureStreaming``, studio's
|
||||||
|
``configureAudioGraph``, the editor's ``src/host.js``), and of the plugin
|
||||||
|
``setup(app, context)`` contract in Principle III: dependencies flow one way,
|
||||||
|
``server -> routers -> appstate``, and nothing imports back up.
|
||||||
|
|
||||||
|
Two properties this shape buys, both load-bearing:
|
||||||
|
|
||||||
|
* **``import appstate`` performs no IO and constructs nothing.** ``server``
|
||||||
|
still owns construction, so the ~49 test fixtures that do
|
||||||
|
``sys.modules.pop("server")`` + re-import (to rebuild ``meta_db`` under a
|
||||||
|
patched ``CONFIG_DIR``) keep working untouched — a singleton *owned* here
|
||||||
|
would survive that pop and go stale.
|
||||||
|
* **Reads are late-bound.** Routers must use ``appstate.meta_db``, never
|
||||||
|
``from appstate import meta_db`` — a ``from`` import freezes the binding at
|
||||||
|
its current value, so a later ``configure()`` (or a
|
||||||
|
``monkeypatch.setattr(appstate, "meta_db", fake)``) would not reach the
|
||||||
|
router. This is the same read-only-binding trap as ES ``import``.
|
||||||
|
|
||||||
|
Defaults are ``None`` on purpose: they are inert but *type-honest*, so a router
|
||||||
|
that runs before ``configure()`` fails loudly on ``NoneType`` instead of
|
||||||
|
quietly operating on a stand-in.
|
||||||
|
|
||||||
|
Slots are added here only when a router actually needs one — this is a seam,
|
||||||
|
not a grab-bag for everything in ``server.py``.
|
||||||
|
|
||||||
|
**Why this lives in ``lib/`` and not the repo root.** Because it constructs
|
||||||
|
nothing and does no import-time IO, it satisfies Principle V's rule for ``lib/``
|
||||||
|
modules — and ``lib/`` is the only core directory every packaging path already
|
||||||
|
copies: the Dockerfile (``COPY lib/``), ``docker-compose.yml``, and
|
||||||
|
feedback-desktop's ``bundle-slopsmith.sh`` (``cp -r lib``). All three also put
|
||||||
|
both the bundle root and ``lib/`` on ``sys.path``. A root-level module ships in
|
||||||
|
Docker but is silently dropped from the packaged desktop app, whose bundler
|
||||||
|
copies a hardcoded file list — that regression is what moved this file here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
|
||||||
|
meta_db = None
|
||||||
|
audio_effect_mappings = None
|
||||||
|
|
||||||
|
# Declared up front so `configure()` can reject a typo'd or stale keyword
|
||||||
|
# instead of silently creating a new global that nothing ever reads. A seam
|
||||||
|
# whose wiring can no-op undetected is worse than no seam.
|
||||||
|
_SLOTS = frozenset({"meta_db", "audio_effect_mappings"})
|
||||||
|
|
||||||
|
|
||||||
|
def configure(**kwargs) -> None:
|
||||||
|
"""Publish `server`'s singletons into this module. Called once per
|
||||||
|
`server` import (and again on re-import), so it must be idempotent."""
|
||||||
|
unknown = set(kwargs) - _SLOTS
|
||||||
|
if unknown:
|
||||||
|
raise TypeError(
|
||||||
|
f"appstate.configure() got unknown slot(s): {sorted(unknown)}. "
|
||||||
|
f"Known slots: {sorted(_SLOTS)}. Add the name to _SLOTS if a router "
|
||||||
|
f"genuinely needs it."
|
||||||
|
)
|
||||||
|
globals().update(kwargs)
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Core-owned song/tone -> audio-effect-provider mapping index.
|
||||||
|
|
||||||
|
Extracted verbatim from ``server.py`` (R3). ``server.py`` still owns the
|
||||||
|
``audio_effect_mappings`` singleton; this module only supplies the class, so
|
||||||
|
nothing here touches config paths at import time — the caller passes
|
||||||
|
``config_dir`` in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class AudioEffectsMappingDB:
|
||||||
|
"""Core-owned public song/tone -> provider mapping index.
|
||||||
|
|
||||||
|
Providers own the preset/chain rows addressed by provider_ref. Core owns
|
||||||
|
the cross-provider routing index and the active mapping per song/tone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config_dir: Path):
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.db_path = str(config_dir / "audio_effects.db")
|
||||||
|
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||||
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS audio_effect_mappings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
song_key TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL DEFAULT '',
|
||||||
|
tone_key TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
provider_ref TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(song_key, tone_key, provider_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS audio_effect_active_mappings (
|
||||||
|
song_key TEXT NOT NULL,
|
||||||
|
tone_key TEXT NOT NULL,
|
||||||
|
mapping_id INTEGER NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (song_key, tone_key),
|
||||||
|
FOREIGN KEY (mapping_id) REFERENCES audio_effect_mappings(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_provider "
|
||||||
|
"ON audio_effect_mappings(provider_id)"
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_filename "
|
||||||
|
"ON audio_effect_mappings(filename)"
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _text(value, *, field: str, limit: int, allow_empty: bool = False) -> str:
|
||||||
|
if value is None:
|
||||||
|
text = ""
|
||||||
|
elif not isinstance(value, str):
|
||||||
|
raise ValueError(f"{field} must be a string")
|
||||||
|
else:
|
||||||
|
text = value.strip()
|
||||||
|
if not text and not allow_empty:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
if len(text) > limit:
|
||||||
|
raise ValueError(f"{field} is too long")
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mapping_id(value) -> int | None:
|
||||||
|
# Bind only values SQLite can store as an INTEGER; an out-of-range id is a
|
||||||
|
# clean miss (404), not a 500 at bind time.
|
||||||
|
if isinstance(value, int) and not isinstance(value, bool) and -(2 ** 63) <= value < 2 ** 63:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _field(data: dict, *keys):
|
||||||
|
# Select the first present snake/camel alias by key, not by truthiness, so a
|
||||||
|
# falsey non-string value (false/0) still reaches _text() and is rejected
|
||||||
|
# instead of being silently swallowed by an `or` chain.
|
||||||
|
for key in keys:
|
||||||
|
if key in data:
|
||||||
|
return data[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _metadata(value) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "{}"
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("metadata must be an object")
|
||||||
|
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True)
|
||||||
|
if len(encoded) > 8192:
|
||||||
|
raise ValueError("metadata is too large")
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row(row) -> dict | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
metadata = {}
|
||||||
|
try:
|
||||||
|
metadata = json.loads(row[8]) if row[8] else {}
|
||||||
|
except Exception:
|
||||||
|
metadata = {}
|
||||||
|
return {
|
||||||
|
"id": int(row[0]),
|
||||||
|
"song_key": row[1],
|
||||||
|
"filename": row[2] or "",
|
||||||
|
"tone_key": row[3],
|
||||||
|
"provider_id": row[4],
|
||||||
|
"provider_ref": row[5],
|
||||||
|
"label": row[6] or "",
|
||||||
|
"source": row[7] or "manual",
|
||||||
|
"metadata": metadata if isinstance(metadata, dict) else {},
|
||||||
|
"created_at": row[9] or "",
|
||||||
|
"updated_at": row[10] or "",
|
||||||
|
"active": bool(row[11]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _select_sql(self) -> str:
|
||||||
|
return """
|
||||||
|
SELECT m.id, m.song_key, m.filename, m.tone_key, m.provider_id,
|
||||||
|
m.provider_ref, m.label, m.source, m.metadata_json,
|
||||||
|
m.created_at, m.updated_at,
|
||||||
|
CASE WHEN a.mapping_id IS NULL THEN 0 ELSE 1 END AS active
|
||||||
|
FROM audio_effect_mappings m
|
||||||
|
LEFT JOIN audio_effect_active_mappings a
|
||||||
|
ON a.song_key = m.song_key AND a.tone_key = m.tone_key AND a.mapping_id = m.id
|
||||||
|
"""
|
||||||
|
|
||||||
|
def list(self, *, song_key: str = "", filename: str = "", tone_key: str = "", provider_id: str = "") -> list[dict]:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[str] = []
|
||||||
|
song_key = self._text(song_key, field="song_key", limit=240, allow_empty=True)
|
||||||
|
filename = self._text(filename, field="filename", limit=500, allow_empty=True)
|
||||||
|
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
||||||
|
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||||
|
if song_key and filename:
|
||||||
|
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
||||||
|
params.extend([song_key, filename])
|
||||||
|
elif song_key:
|
||||||
|
clauses.append("m.song_key = ?")
|
||||||
|
params.append(song_key)
|
||||||
|
elif filename:
|
||||||
|
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
||||||
|
params.extend([filename, filename])
|
||||||
|
if tone_key:
|
||||||
|
clauses.append("m.tone_key = ?")
|
||||||
|
params.append(tone_key)
|
||||||
|
if provider_id:
|
||||||
|
clauses.append("m.provider_id = ?")
|
||||||
|
params.append(provider_id)
|
||||||
|
sql = self._select_sql()
|
||||||
|
if clauses:
|
||||||
|
sql += " WHERE " + " AND ".join(clauses)
|
||||||
|
sql += " ORDER BY m.song_key COLLATE NOCASE, m.tone_key COLLATE NOCASE, m.provider_id COLLATE NOCASE"
|
||||||
|
with self._lock:
|
||||||
|
rows = self.conn.execute(sql, params).fetchall()
|
||||||
|
return [self._row(row) for row in rows]
|
||||||
|
|
||||||
|
def get(self, mapping_id: int) -> dict | None:
|
||||||
|
mapping_id = self._mapping_id(mapping_id)
|
||||||
|
if mapping_id is None:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
row = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
||||||
|
return self._row(row)
|
||||||
|
|
||||||
|
def upsert(self, data: dict) -> dict:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("mapping body must be an object")
|
||||||
|
filename = self._text(data.get("filename", ""), field="filename", limit=500, allow_empty=True)
|
||||||
|
song_key_raw = self._field(data, "song_key", "songKey")
|
||||||
|
if song_key_raw is None or song_key_raw == "":
|
||||||
|
song_key_raw = filename
|
||||||
|
song_key = self._text(song_key_raw, field="song_key", limit=240)
|
||||||
|
tone_key = self._text(self._field(data, "tone_key", "toneKey"), field="tone_key", limit=160, allow_empty=True)
|
||||||
|
provider_id = self._text(self._field(data, "provider_id", "providerId"), field="provider_id", limit=96)
|
||||||
|
provider_ref = self._text(self._field(data, "provider_ref", "providerRef"), field="provider_ref", limit=240)
|
||||||
|
label = self._text(data.get("label", ""), field="label", limit=160, allow_empty=True)
|
||||||
|
source = self._text(data.get("source", "manual"), field="source", limit=40, allow_empty=True) or "manual"
|
||||||
|
metadata_json = self._metadata(data.get("metadata", {}))
|
||||||
|
with self._lock:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audio_effect_mappings
|
||||||
|
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(song_key, tone_key, provider_id) DO UPDATE SET
|
||||||
|
-- Only overwrite filename when a non-empty one was supplied; an
|
||||||
|
-- omitted/empty filename must preserve the stored value (it's an
|
||||||
|
-- alternate lookup key for list(..., filename=...)).
|
||||||
|
filename=CASE WHEN excluded.filename <> '' THEN excluded.filename ELSE audio_effect_mappings.filename END,
|
||||||
|
provider_ref=excluded.provider_ref,
|
||||||
|
label=excluded.label,
|
||||||
|
source=excluded.source,
|
||||||
|
metadata_json=excluded.metadata_json,
|
||||||
|
updated_at=datetime('now')
|
||||||
|
""",
|
||||||
|
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json),
|
||||||
|
)
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT id FROM audio_effect_mappings WHERE song_key = ? AND tone_key = ? AND provider_id = ?",
|
||||||
|
(song_key, tone_key, provider_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("failed to create audio-effects mapping")
|
||||||
|
mapping_id = int(row[0])
|
||||||
|
if data.get("active") is True:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
||||||
|
mapping_id=excluded.mapping_id,
|
||||||
|
updated_at=datetime('now')
|
||||||
|
""",
|
||||||
|
(song_key, tone_key, mapping_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return self.get(mapping_id)
|
||||||
|
|
||||||
|
def delete(self, mapping_id: int, *, provider_id: str = "") -> bool:
|
||||||
|
mapping_id = self._mapping_id(mapping_id)
|
||||||
|
if mapping_id is None:
|
||||||
|
return False
|
||||||
|
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||||
|
with self._lock:
|
||||||
|
if provider_id:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"DELETE FROM audio_effect_mappings WHERE id = ? AND provider_id = ?",
|
||||||
|
(mapping_id, provider_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = self.conn.execute("DELETE FROM audio_effect_mappings WHERE id = ?", (mapping_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def activate(self, mapping_id: int, *, provider_id: str = "") -> dict | None:
|
||||||
|
mapping_id = self._mapping_id(mapping_id)
|
||||||
|
if mapping_id is None:
|
||||||
|
return None
|
||||||
|
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||||
|
with self._lock:
|
||||||
|
row = self.conn.execute(
|
||||||
|
self._select_sql() + " WHERE m.id = ?",
|
||||||
|
(mapping_id,),
|
||||||
|
).fetchone()
|
||||||
|
mapping = self._row(row)
|
||||||
|
if not mapping or (provider_id and mapping["provider_id"] != provider_id):
|
||||||
|
return None
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
||||||
|
mapping_id=excluded.mapping_id,
|
||||||
|
updated_at=datetime('now')
|
||||||
|
""",
|
||||||
|
(mapping["song_key"], mapping["tone_key"], mapping_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
selected = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
||||||
|
return self._row(selected)
|
||||||
|
|
||||||
|
def clear_active(self, *, song_key: str, tone_key: str) -> bool:
|
||||||
|
song_key = self._text(song_key, field="song_key", limit=240)
|
||||||
|
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
||||||
|
with self._lock:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"DELETE FROM audio_effect_active_mappings WHERE song_key = ? AND tone_key = ?",
|
||||||
|
(song_key, tone_key),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""FastAPI route modules extracted from ``server.py`` (R3).
|
||||||
|
|
||||||
|
Each module here exposes a module-level ``router`` (a ``fastapi.APIRouter``)
|
||||||
|
that ``server.py`` mounts with ``app.include_router(...)`` at the point in the
|
||||||
|
file where those routes used to be defined — FastAPI matches routes in
|
||||||
|
registration order, so keeping the mount site preserves it.
|
||||||
|
|
||||||
|
**Routers must never ``import server``.** They reach core singletons through
|
||||||
|
the injected seam instead::
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
|
||||||
|
@router.get("/api/thing")
|
||||||
|
def get_thing():
|
||||||
|
return appstate.meta_db.thing()
|
||||||
|
|
||||||
|
and always as a **module attribute, at call time** — never
|
||||||
|
``from appstate import meta_db``, which freezes the binding and defeats both a
|
||||||
|
later ``appstate.configure()`` and ``monkeypatch.setattr``. See ``appstate.py``.
|
||||||
|
|
||||||
|
Dependencies flow one way: ``server -> routers -> appstate``.
|
||||||
|
|
||||||
|
**Why this lives under ``lib/``.** ``lib/`` is the only core directory every
|
||||||
|
packaging path already copies wholesale — the Dockerfile (``COPY lib/``),
|
||||||
|
``docker-compose.yml``, and feedback-desktop's ``bundle-slopsmith.sh``
|
||||||
|
(``cp -r lib``) — and all three put it on ``sys.path``. A root-level package
|
||||||
|
ships in Docker but is silently dropped from the packaged desktop app, whose
|
||||||
|
bundler copies a hardcoded file list. Route modules import nothing at module
|
||||||
|
scope beyond FastAPI and ``appstate``, so they do no import-time IO and satisfy
|
||||||
|
Principle V's rule for ``lib/``.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Audio-effects mapping API — the core-owned song/tone -> provider routing index.
|
||||||
|
|
||||||
|
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||||
|
(``@app`` -> ``@router``) and the singleton read (``audio_effect_mappings`` ->
|
||||||
|
``appstate.audio_effect_mappings``) changed. The read must stay a module
|
||||||
|
attribute so a re-imported ``server`` re-publishes a fresh DB into the seam and
|
||||||
|
`monkeypatch.setattr` reaches this module — see ``appstate.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_effects_error(exc: Exception):
|
||||||
|
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/audio-effects/mappings")
|
||||||
|
def list_audio_effect_mappings(
|
||||||
|
song_key: str = Query(""),
|
||||||
|
filename: str = Query(""),
|
||||||
|
tone_key: str = Query(""),
|
||||||
|
provider_id: str = Query(""),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"mappings": appstate.audio_effect_mappings.list(
|
||||||
|
song_key=song_key,
|
||||||
|
filename=filename,
|
||||||
|
tone_key=tone_key,
|
||||||
|
provider_id=provider_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
return _audio_effects_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/audio-effects/mappings")
|
||||||
|
def upsert_audio_effect_mapping(data: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
mapping = appstate.audio_effect_mappings.upsert(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _audio_effects_error(exc)
|
||||||
|
return {"ok": True, "mapping": mapping}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/audio-effects/mappings/{mapping_id}")
|
||||||
|
def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")):
|
||||||
|
try:
|
||||||
|
deleted = appstate.audio_effect_mappings.delete(mapping_id, provider_id=provider_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _audio_effects_error(exc)
|
||||||
|
if not deleted:
|
||||||
|
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/audio-effects/mappings/{mapping_id}/activate")
|
||||||
|
def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)):
|
||||||
|
try:
|
||||||
|
provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId")
|
||||||
|
mapping = appstate.audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _audio_effects_error(exc)
|
||||||
|
if not mapping:
|
||||||
|
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||||
|
return {"ok": True, "mapping": mapping}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/audio-effects/active-mapping")
|
||||||
|
def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")):
|
||||||
|
try:
|
||||||
|
cleared = appstate.audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _audio_effects_error(exc)
|
||||||
|
return {"ok": True, "cleared": cleared}
|
||||||
@@ -21,7 +21,7 @@ configure_logging()
|
|||||||
|
|
||||||
log = logging.getLogger("feedBack.server")
|
log = logging.getLogger("feedBack.server")
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Query
|
from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
|
||||||
@@ -63,6 +63,15 @@ from metadata_db import (
|
|||||||
_tuning_group_key_sql,
|
_tuning_group_key_sql,
|
||||||
next_library_cursor,
|
next_library_cursor,
|
||||||
)
|
)
|
||||||
|
# The audio-effect routing index. Same shape as metadata_db: the class lives in
|
||||||
|
# its own module, the `audio_effect_mappings` singleton below stays here.
|
||||||
|
from audio_effects_db import AudioEffectsMappingDB
|
||||||
|
# The router seam. Imported as a module (never `from appstate import ...`) so
|
||||||
|
# `appstate.configure(...)` below publishes into the same namespace routers read.
|
||||||
|
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||||
|
import appstate
|
||||||
|
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||||
|
from routers import audio_effects
|
||||||
import sloppak as sloppak_mod
|
import sloppak as sloppak_mod
|
||||||
import drums as drums_mod
|
import drums as drums_mod
|
||||||
import notation as notation_mod
|
import notation as notation_mod
|
||||||
@@ -347,283 +356,17 @@ def _env_flag(name: str) -> bool:
|
|||||||
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
|
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
|
||||||
|
|
||||||
|
|
||||||
class AudioEffectsMappingDB:
|
|
||||||
"""Core-owned public song/tone -> provider mapping index.
|
|
||||||
|
|
||||||
Providers own the preset/chain rows addressed by provider_ref. Core owns
|
|
||||||
the cross-provider routing index and the active mapping per song/tone.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.db_path = str(CONFIG_DIR / "audio_effects.db")
|
|
||||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
||||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
||||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
|
||||||
self.conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS audio_effect_mappings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
song_key TEXT NOT NULL,
|
|
||||||
filename TEXT NOT NULL DEFAULT '',
|
|
||||||
tone_key TEXT NOT NULL,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
provider_ref TEXT NOT NULL,
|
|
||||||
label TEXT NOT NULL DEFAULT '',
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(song_key, tone_key, provider_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
self.conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS audio_effect_active_mappings (
|
|
||||||
song_key TEXT NOT NULL,
|
|
||||||
tone_key TEXT NOT NULL,
|
|
||||||
mapping_id INTEGER NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (song_key, tone_key),
|
|
||||||
FOREIGN KEY (mapping_id) REFERENCES audio_effect_mappings(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_provider "
|
|
||||||
"ON audio_effect_mappings(provider_id)"
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_filename "
|
|
||||||
"ON audio_effect_mappings(filename)"
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _text(value, *, field: str, limit: int, allow_empty: bool = False) -> str:
|
|
||||||
if value is None:
|
|
||||||
text = ""
|
|
||||||
elif not isinstance(value, str):
|
|
||||||
raise ValueError(f"{field} must be a string")
|
|
||||||
else:
|
|
||||||
text = value.strip()
|
|
||||||
if not text and not allow_empty:
|
|
||||||
raise ValueError(f"{field} is required")
|
|
||||||
if len(text) > limit:
|
|
||||||
raise ValueError(f"{field} is too long")
|
|
||||||
return text
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _mapping_id(value) -> int | None:
|
|
||||||
# Bind only values SQLite can store as an INTEGER; an out-of-range id is a
|
|
||||||
# clean miss (404), not a 500 at bind time.
|
|
||||||
if isinstance(value, int) and not isinstance(value, bool) and -(2 ** 63) <= value < 2 ** 63:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _field(data: dict, *keys):
|
|
||||||
# Select the first present snake/camel alias by key, not by truthiness, so a
|
|
||||||
# falsey non-string value (false/0) still reaches _text() and is rejected
|
|
||||||
# instead of being silently swallowed by an `or` chain.
|
|
||||||
for key in keys:
|
|
||||||
if key in data:
|
|
||||||
return data[key]
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _metadata(value) -> str:
|
|
||||||
if value is None:
|
|
||||||
return "{}"
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError("metadata must be an object")
|
|
||||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True)
|
|
||||||
if len(encoded) > 8192:
|
|
||||||
raise ValueError("metadata is too large")
|
|
||||||
return encoded
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _row(row) -> dict | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
metadata = {}
|
|
||||||
try:
|
|
||||||
metadata = json.loads(row[8]) if row[8] else {}
|
|
||||||
except Exception:
|
|
||||||
metadata = {}
|
|
||||||
return {
|
|
||||||
"id": int(row[0]),
|
|
||||||
"song_key": row[1],
|
|
||||||
"filename": row[2] or "",
|
|
||||||
"tone_key": row[3],
|
|
||||||
"provider_id": row[4],
|
|
||||||
"provider_ref": row[5],
|
|
||||||
"label": row[6] or "",
|
|
||||||
"source": row[7] or "manual",
|
|
||||||
"metadata": metadata if isinstance(metadata, dict) else {},
|
|
||||||
"created_at": row[9] or "",
|
|
||||||
"updated_at": row[10] or "",
|
|
||||||
"active": bool(row[11]),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _select_sql(self) -> str:
|
|
||||||
return """
|
|
||||||
SELECT m.id, m.song_key, m.filename, m.tone_key, m.provider_id,
|
|
||||||
m.provider_ref, m.label, m.source, m.metadata_json,
|
|
||||||
m.created_at, m.updated_at,
|
|
||||||
CASE WHEN a.mapping_id IS NULL THEN 0 ELSE 1 END AS active
|
|
||||||
FROM audio_effect_mappings m
|
|
||||||
LEFT JOIN audio_effect_active_mappings a
|
|
||||||
ON a.song_key = m.song_key AND a.tone_key = m.tone_key AND a.mapping_id = m.id
|
|
||||||
"""
|
|
||||||
|
|
||||||
def list(self, *, song_key: str = "", filename: str = "", tone_key: str = "", provider_id: str = "") -> list[dict]:
|
|
||||||
clauses: list[str] = []
|
|
||||||
params: list[str] = []
|
|
||||||
song_key = self._text(song_key, field="song_key", limit=240, allow_empty=True)
|
|
||||||
filename = self._text(filename, field="filename", limit=500, allow_empty=True)
|
|
||||||
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
|
||||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
|
||||||
if song_key and filename:
|
|
||||||
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
|
||||||
params.extend([song_key, filename])
|
|
||||||
elif song_key:
|
|
||||||
clauses.append("m.song_key = ?")
|
|
||||||
params.append(song_key)
|
|
||||||
elif filename:
|
|
||||||
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
|
||||||
params.extend([filename, filename])
|
|
||||||
if tone_key:
|
|
||||||
clauses.append("m.tone_key = ?")
|
|
||||||
params.append(tone_key)
|
|
||||||
if provider_id:
|
|
||||||
clauses.append("m.provider_id = ?")
|
|
||||||
params.append(provider_id)
|
|
||||||
sql = self._select_sql()
|
|
||||||
if clauses:
|
|
||||||
sql += " WHERE " + " AND ".join(clauses)
|
|
||||||
sql += " ORDER BY m.song_key COLLATE NOCASE, m.tone_key COLLATE NOCASE, m.provider_id COLLATE NOCASE"
|
|
||||||
with self._lock:
|
|
||||||
rows = self.conn.execute(sql, params).fetchall()
|
|
||||||
return [self._row(row) for row in rows]
|
|
||||||
|
|
||||||
def get(self, mapping_id: int) -> dict | None:
|
|
||||||
mapping_id = self._mapping_id(mapping_id)
|
|
||||||
if mapping_id is None:
|
|
||||||
return None
|
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
|
||||||
return self._row(row)
|
|
||||||
|
|
||||||
def upsert(self, data: dict) -> dict:
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("mapping body must be an object")
|
|
||||||
filename = self._text(data.get("filename", ""), field="filename", limit=500, allow_empty=True)
|
|
||||||
song_key_raw = self._field(data, "song_key", "songKey")
|
|
||||||
if song_key_raw is None or song_key_raw == "":
|
|
||||||
song_key_raw = filename
|
|
||||||
song_key = self._text(song_key_raw, field="song_key", limit=240)
|
|
||||||
tone_key = self._text(self._field(data, "tone_key", "toneKey"), field="tone_key", limit=160, allow_empty=True)
|
|
||||||
provider_id = self._text(self._field(data, "provider_id", "providerId"), field="provider_id", limit=96)
|
|
||||||
provider_ref = self._text(self._field(data, "provider_ref", "providerRef"), field="provider_ref", limit=240)
|
|
||||||
label = self._text(data.get("label", ""), field="label", limit=160, allow_empty=True)
|
|
||||||
source = self._text(data.get("source", "manual"), field="source", limit=40, allow_empty=True) or "manual"
|
|
||||||
metadata_json = self._metadata(data.get("metadata", {}))
|
|
||||||
with self._lock:
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO audio_effect_mappings
|
|
||||||
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT(song_key, tone_key, provider_id) DO UPDATE SET
|
|
||||||
-- Only overwrite filename when a non-empty one was supplied; an
|
|
||||||
-- omitted/empty filename must preserve the stored value (it's an
|
|
||||||
-- alternate lookup key for list(..., filename=...)).
|
|
||||||
filename=CASE WHEN excluded.filename <> '' THEN excluded.filename ELSE audio_effect_mappings.filename END,
|
|
||||||
provider_ref=excluded.provider_ref,
|
|
||||||
label=excluded.label,
|
|
||||||
source=excluded.source,
|
|
||||||
metadata_json=excluded.metadata_json,
|
|
||||||
updated_at=datetime('now')
|
|
||||||
""",
|
|
||||||
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json),
|
|
||||||
)
|
|
||||||
row = self.conn.execute(
|
|
||||||
"SELECT id FROM audio_effect_mappings WHERE song_key = ? AND tone_key = ? AND provider_id = ?",
|
|
||||||
(song_key, tone_key, provider_id),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise ValueError("failed to create audio-effects mapping")
|
|
||||||
mapping_id = int(row[0])
|
|
||||||
if data.get("active") is True:
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
|
||||||
mapping_id=excluded.mapping_id,
|
|
||||||
updated_at=datetime('now')
|
|
||||||
""",
|
|
||||||
(song_key, tone_key, mapping_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return self.get(mapping_id)
|
|
||||||
|
|
||||||
def delete(self, mapping_id: int, *, provider_id: str = "") -> bool:
|
|
||||||
mapping_id = self._mapping_id(mapping_id)
|
|
||||||
if mapping_id is None:
|
|
||||||
return False
|
|
||||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
|
||||||
with self._lock:
|
|
||||||
if provider_id:
|
|
||||||
cur = self.conn.execute(
|
|
||||||
"DELETE FROM audio_effect_mappings WHERE id = ? AND provider_id = ?",
|
|
||||||
(mapping_id, provider_id),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur = self.conn.execute("DELETE FROM audio_effect_mappings WHERE id = ?", (mapping_id,))
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
||||||
def activate(self, mapping_id: int, *, provider_id: str = "") -> dict | None:
|
|
||||||
mapping_id = self._mapping_id(mapping_id)
|
|
||||||
if mapping_id is None:
|
|
||||||
return None
|
|
||||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute(
|
|
||||||
self._select_sql() + " WHERE m.id = ?",
|
|
||||||
(mapping_id,),
|
|
||||||
).fetchone()
|
|
||||||
mapping = self._row(row)
|
|
||||||
if not mapping or (provider_id and mapping["provider_id"] != provider_id):
|
|
||||||
return None
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
|
||||||
mapping_id=excluded.mapping_id,
|
|
||||||
updated_at=datetime('now')
|
|
||||||
""",
|
|
||||||
(mapping["song_key"], mapping["tone_key"], mapping_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
selected = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
|
||||||
return self._row(selected)
|
|
||||||
|
|
||||||
def clear_active(self, *, song_key: str, tone_key: str) -> bool:
|
|
||||||
song_key = self._text(song_key, field="song_key", limit=240)
|
|
||||||
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
|
||||||
with self._lock:
|
|
||||||
cur = self.conn.execute(
|
|
||||||
"DELETE FROM audio_effect_active_mappings WHERE song_key = ? AND tone_key = ?",
|
|
||||||
(song_key, tone_key),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
||||||
|
|
||||||
meta_db = MetadataDB(CONFIG_DIR)
|
meta_db = MetadataDB(CONFIG_DIR)
|
||||||
audio_effect_mappings = AudioEffectsMappingDB()
|
audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)
|
||||||
|
|
||||||
|
# Publish the singletons to the router seam. server.py stays their owner — a
|
||||||
|
# `sys.modules.pop("server")` + re-import must keep rebuilding them under a
|
||||||
|
# patched CONFIG_DIR — and `routers/` read them back as `appstate.<name>` at
|
||||||
|
# call time. See appstate.py for why the reads must be late-bound.
|
||||||
|
appstate.configure(
|
||||||
|
meta_db=meta_db,
|
||||||
|
audio_effect_mappings=audio_effect_mappings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LocalLibraryProvider:
|
class LocalLibraryProvider:
|
||||||
@@ -6251,70 +5994,9 @@ def delete_loop(loop_id: int):
|
|||||||
|
|
||||||
|
|
||||||
# ── Audio Effects Mapping API ───────────────────────────────────────────────
|
# ── Audio Effects Mapping API ───────────────────────────────────────────────
|
||||||
|
# Mounted here, where these routes used to be defined: FastAPI matches in
|
||||||
def _audio_effects_error(exc: Exception):
|
# registration order, so the mount site preserves it.
|
||||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
app.include_router(audio_effects.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/audio-effects/mappings")
|
|
||||||
def list_audio_effect_mappings(
|
|
||||||
song_key: str = Query(""),
|
|
||||||
filename: str = Query(""),
|
|
||||||
tone_key: str = Query(""),
|
|
||||||
provider_id: str = Query(""),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return {
|
|
||||||
"mappings": audio_effect_mappings.list(
|
|
||||||
song_key=song_key,
|
|
||||||
filename=filename,
|
|
||||||
tone_key=tone_key,
|
|
||||||
provider_id=provider_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
except ValueError as exc:
|
|
||||||
return _audio_effects_error(exc)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/audio-effects/mappings")
|
|
||||||
def upsert_audio_effect_mapping(data: dict = Body(...)):
|
|
||||||
try:
|
|
||||||
mapping = audio_effect_mappings.upsert(data)
|
|
||||||
except ValueError as exc:
|
|
||||||
return _audio_effects_error(exc)
|
|
||||||
return {"ok": True, "mapping": mapping}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/audio-effects/mappings/{mapping_id}")
|
|
||||||
def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")):
|
|
||||||
try:
|
|
||||||
deleted = audio_effect_mappings.delete(mapping_id, provider_id=provider_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
return _audio_effects_error(exc)
|
|
||||||
if not deleted:
|
|
||||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/audio-effects/mappings/{mapping_id}/activate")
|
|
||||||
def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)):
|
|
||||||
try:
|
|
||||||
provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId")
|
|
||||||
mapping = audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
return _audio_effects_error(exc)
|
|
||||||
if not mapping:
|
|
||||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
|
||||||
return {"ok": True, "mapping": mapping}
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/audio-effects/active-mapping")
|
|
||||||
def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")):
|
|
||||||
try:
|
|
||||||
cleared = audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key)
|
|
||||||
except ValueError as exc:
|
|
||||||
return _audio_effects_error(exc)
|
|
||||||
return {"ok": True, "cleared": cleared}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Settings API ──────────────────────────────────────────────────────────────
|
# ── Settings API ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""The router seam (`appstate.py`).
|
||||||
|
|
||||||
|
The load-bearing assertion here is `test_server_wires_the_seam`: that `server`
|
||||||
|
actually calls `appstate.configure(...)`. Every other test in this file would
|
||||||
|
pass just fine against a seam nothing ever wires up — the same class of silent
|
||||||
|
no-op that bit the frontend refactor twice when a scripted `setHostHooks` edit
|
||||||
|
stopped matching its anchor. Unit tests cannot see wiring unless you make them
|
||||||
|
look at it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
|
||||||
|
|
||||||
|
def _close_server_dbs(mod):
|
||||||
|
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
getattr(mod, "_join_background_db_threads", lambda: None)()
|
||||||
|
conn.close()
|
||||||
|
ae_conn = getattr(getattr(mod, "audio_effect_mappings", None), "conn", None)
|
||||||
|
if ae_conn is not None:
|
||||||
|
ae_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def isolated_server(tmp_path, monkeypatch):
|
||||||
|
"""A freshly imported `server` bound to a throwaway CONFIG_DIR.
|
||||||
|
|
||||||
|
Importing `server` constructs `MetadataDB` + `AudioEffectsMappingDB` at
|
||||||
|
module level, so it MUST be re-imported under a patched CONFIG_DIR — an
|
||||||
|
unguarded `import server` would create/mutate the developer's real
|
||||||
|
`~/.local/share/feedback` databases. Same idiom as the other ~49
|
||||||
|
server-importing suites.
|
||||||
|
|
||||||
|
Teardown restores the appstate slots as well as closing the connections:
|
||||||
|
leaving `appstate.meta_db` published but pointing at a closed sqlite handle
|
||||||
|
would hand a later test (or router) a live-looking, dead singleton.
|
||||||
|
"""
|
||||||
|
previous = (appstate.meta_db, appstate.audio_effect_mappings)
|
||||||
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
mod = importlib.import_module("server")
|
||||||
|
yield mod
|
||||||
|
_close_server_dbs(mod)
|
||||||
|
# Leave no half-torn-down `server` behind: the next fixture re-imports it.
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
appstate.configure(meta_db=previous[0], audio_effect_mappings=previous[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_is_side_effect_free():
|
||||||
|
"""`import appstate` must construct nothing and touch no disk.
|
||||||
|
|
||||||
|
This is why the ~49 fixtures that `sys.modules.pop("server")` and re-import
|
||||||
|
(to rebuild `meta_db` under a patched CONFIG_DIR) keep working untouched:
|
||||||
|
server owns construction, appstate only mirrors it. A singleton *owned*
|
||||||
|
here would survive that pop and go stale.
|
||||||
|
"""
|
||||||
|
sys.modules.pop("appstate", None)
|
||||||
|
fresh = importlib.import_module("appstate")
|
||||||
|
try:
|
||||||
|
assert fresh.meta_db is None
|
||||||
|
assert fresh.audio_effect_mappings is None
|
||||||
|
finally:
|
||||||
|
sys.modules["appstate"] = appstate
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_publishes_known_slots():
|
||||||
|
sentinel = object()
|
||||||
|
original = appstate.meta_db
|
||||||
|
try:
|
||||||
|
appstate.configure(meta_db=sentinel)
|
||||||
|
assert appstate.meta_db is sentinel
|
||||||
|
finally:
|
||||||
|
appstate.configure(meta_db=original)
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_is_idempotent():
|
||||||
|
"""server re-imports call configure() again; the last write must win."""
|
||||||
|
original = appstate.meta_db
|
||||||
|
try:
|
||||||
|
appstate.configure(meta_db="first")
|
||||||
|
appstate.configure(meta_db="second")
|
||||||
|
assert appstate.meta_db == "second"
|
||||||
|
finally:
|
||||||
|
appstate.configure(meta_db=original)
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_rejects_an_unknown_slot():
|
||||||
|
"""A typo'd or stale keyword must raise, not silently create a global that
|
||||||
|
nothing reads. A seam whose wiring can no-op undetected is worse than none."""
|
||||||
|
with pytest.raises(TypeError, match="unknown slot"):
|
||||||
|
appstate.configure(met_db="typo")
|
||||||
|
assert not hasattr(appstate, "met_db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_bound_read_sees_a_later_configure():
|
||||||
|
"""Routers must read `appstate.meta_db`, never `from appstate import meta_db`.
|
||||||
|
This pins the property that makes that rule work."""
|
||||||
|
def router_style_read():
|
||||||
|
return appstate.meta_db # module attribute, resolved at call time
|
||||||
|
|
||||||
|
original = appstate.meta_db
|
||||||
|
try:
|
||||||
|
appstate.configure(meta_db="before")
|
||||||
|
assert router_style_read() == "before"
|
||||||
|
appstate.configure(meta_db="after")
|
||||||
|
assert router_style_read() == "after"
|
||||||
|
finally:
|
||||||
|
appstate.configure(meta_db=original)
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_wires_the_seam(isolated_server):
|
||||||
|
"""The one that catches a dropped `appstate.configure(...)` call.
|
||||||
|
|
||||||
|
Identity, not truthiness, so a stray re-assignment or a half-applied edit
|
||||||
|
fails here rather than in some router months later.
|
||||||
|
"""
|
||||||
|
assert appstate.meta_db is isolated_server.meta_db
|
||||||
|
assert appstate.audio_effect_mappings is isolated_server.audio_effect_mappings
|
||||||
|
assert appstate.meta_db is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reimporting_server_republishes_the_fresh_singletons(
|
||||||
|
isolated_server, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""The 49-fixture contract, exercised end to end.
|
||||||
|
|
||||||
|
Those fixtures `sys.modules.pop("server")` + re-import to rebuild `meta_db`
|
||||||
|
under a new CONFIG_DIR, and know nothing about appstate. So the seam must
|
||||||
|
re-publish on that second import. This is the test that would fail if
|
||||||
|
`appstate` ever *owned* the singletons: a module-level `meta_db` there
|
||||||
|
survives the pop and the assertions below would still see the FIRST DB.
|
||||||
|
"""
|
||||||
|
first_db = isolated_server.meta_db
|
||||||
|
assert appstate.meta_db is first_db
|
||||||
|
assert str(tmp_path) in first_db.db_path
|
||||||
|
|
||||||
|
second_config = tmp_path / "second"
|
||||||
|
monkeypatch.setenv("CONFIG_DIR", str(second_config))
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
second_server = importlib.import_module("server")
|
||||||
|
try:
|
||||||
|
assert second_server.meta_db is not first_db # genuinely rebuilt
|
||||||
|
assert str(second_config) in second_server.meta_db.db_path
|
||||||
|
assert appstate.meta_db is second_server.meta_db # ...and re-published
|
||||||
|
assert appstate.audio_effect_mappings is second_server.audio_effect_mappings
|
||||||
|
finally:
|
||||||
|
_close_server_dbs(second_server)
|
||||||
|
sys.modules.pop("server", None)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Guard: every first-party module `server.py` imports must be one the packagers copy.
|
||||||
|
|
||||||
|
feedback-desktop's `scripts/bundle-slopsmith.sh` copies a **hardcoded list** from
|
||||||
|
core into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`, `static/`,
|
||||||
|
`plugins/__init__.py`. A new root-level module (say `appstate.py`) ships fine in
|
||||||
|
Docker, imports fine under pytest, and is then *silently dropped* from the
|
||||||
|
packaged desktop app, which dies at startup with:
|
||||||
|
|
||||||
|
File ".../Resources/slopsmith/server.py", line 71, in <module>
|
||||||
|
import appstate
|
||||||
|
ModuleNotFoundError: No module named 'appstate'
|
||||||
|
|
||||||
|
That shipped once. This test is why it can't ship twice: it walks `server.py`'s
|
||||||
|
module-level imports, keeps the ones that resolve inside this repo, and asserts
|
||||||
|
each lives under a directory every packaging path already copies wholesale.
|
||||||
|
|
||||||
|
If you add a first-party module for `server.py`, put it in `lib/` — the one core
|
||||||
|
directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
|
||||||
|
bundler (`cp -r lib`) all copy, and that all three put on `sys.path`. If you
|
||||||
|
genuinely need it at the repo root, you must also teach `bundle-slopsmith.sh`,
|
||||||
|
the `Dockerfile`, `.dockerignore`, and `docker-compose.yml` about it — and then
|
||||||
|
update `BUNDLED_ROOTS` below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Directories every packaging path copies wholesale, plus the files copied by name.
|
||||||
|
BUNDLED_ROOTS = ("lib", "plugins", "data", "static")
|
||||||
|
BUNDLED_FILES = ("server.py", "main.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _server_toplevel_imports():
|
||||||
|
"""Module names imported at `server.py`'s top level (not inside a function)."""
|
||||||
|
tree = ast.parse((REPO_ROOT / "server.py").read_text())
|
||||||
|
names = set()
|
||||||
|
for node in tree.body: # top level only — lazy imports are fine
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
names.update(a.name.split(".")[0] for a in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||||
|
names.add(node.module.split(".")[0])
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
# `spec.origin` is not always a path: built-in and frozen stdlib modules use
|
||||||
|
# these sentinels. `Path("frozen").resolve()` would land inside the repo and
|
||||||
|
# report `os` as first-party — so filter them before touching the filesystem.
|
||||||
|
_NON_PATH_ORIGINS = {"built-in", "frozen", "namespace"}
|
||||||
|
|
||||||
|
|
||||||
|
def _first_party_origin(name):
|
||||||
|
"""Path of `name` if it resolves inside this repo, else None (stdlib/site-package)."""
|
||||||
|
try:
|
||||||
|
spec = importlib.util.find_spec(name)
|
||||||
|
except (ImportError, ValueError):
|
||||||
|
return None
|
||||||
|
if spec is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if spec.origin and spec.origin not in _NON_PATH_ORIGINS:
|
||||||
|
origin = pathlib.Path(spec.origin)
|
||||||
|
else:
|
||||||
|
# Namespace/frozen: fall back to the first search location, if any.
|
||||||
|
locations = list(getattr(spec, "submodule_search_locations", None) or [])
|
||||||
|
if not locations:
|
||||||
|
return None
|
||||||
|
origin = pathlib.Path(locations[0])
|
||||||
|
|
||||||
|
if not origin.is_absolute():
|
||||||
|
return None # a sentinel, not a real path
|
||||||
|
origin = origin.resolve()
|
||||||
|
try:
|
||||||
|
origin.relative_to(REPO_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return None # outside the repo → a dependency
|
||||||
|
return origin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", _server_toplevel_imports())
|
||||||
|
def test_server_import_is_bundled(name):
|
||||||
|
origin = _first_party_origin(name)
|
||||||
|
if origin is None:
|
||||||
|
return # stdlib or an installed dependency
|
||||||
|
|
||||||
|
rel = origin.relative_to(REPO_ROOT)
|
||||||
|
if rel.as_posix() in BUNDLED_FILES or rel.parts[0] in BUNDLED_ROOTS:
|
||||||
|
return
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
f"server.py imports `{name}` from {rel}, which no packager copies.\n"
|
||||||
|
f"The desktop bundler (scripts/bundle-slopsmith.sh) copies only "
|
||||||
|
f"{BUNDLED_FILES} and {BUNDLED_ROOTS}/, so the packaged app would die "
|
||||||
|
f"at startup with ModuleNotFoundError: No module named '{name}'.\n"
|
||||||
|
f"Move it under lib/, or teach bundle-slopsmith.sh + Dockerfile + "
|
||||||
|
f".dockerignore + docker-compose.yml about it and update BUNDLED_ROOTS."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_seam_and_routers_live_under_lib():
|
||||||
|
"""Pin the two that already caused a shipped break."""
|
||||||
|
for name in ("appstate", "routers"):
|
||||||
|
origin = _first_party_origin(name)
|
||||||
|
assert origin is not None, f"{name} does not resolve inside the repo"
|
||||||
|
assert origin.relative_to(REPO_ROOT).parts[0] == "lib", (
|
||||||
|
f"{name} resolved to {origin.relative_to(REPO_ROOT)}; it must live "
|
||||||
|
f"under lib/ or the packaged desktop app will not ship it"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user