mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
refactor(server): extract the chart routes into routers/chart.py (R3) (#846)
Unblocked by the DLC-path substrate (#843): chart's only server-module deps are now app + meta_db (both seam); _get_dlc_dir/_resolve_dlc_path come from dlc_paths, sloppak/loose detection from the shared lib modules. 4 routes (split/unsplit/ work/fileinfo), meta_db-only otherwise. 0 setattr targets, 0 helpers to relocate. Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db. include_router at the original site; 143-route table identical to origin/main. No test retargeting. server.py: 8,003 -> 7,909. Verified: pyflakes clean on the router; no new undefined/dead in server.py; route table identical; pytest 2401 passed (74 across work_charts/context_menu/ group_filter/packaging); eslint 0. Boot smoke: chart/work 200, chart/fileinfo resolves the real pack path through _resolve_dlc_path. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c8701991cb
commit
7c87538d6b
+1
-1
@@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
next root-level module can't ship broken.
|
next root-level module can't ship broken.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **`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 — the single largest handler). 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/` — 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). 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
|
- **`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
|
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
|
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||||
|
|||||||
@@ -55,8 +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`
|
||||||
(8,003 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
(7,909 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||||
extractions and five `routers/` modules) ·
|
extractions and six `routers/` modules) ·
|
||||||
`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,115 @@
|
|||||||
|
"""Chart-level endpoints — split/unsplit a chart from its work, resolve work
|
||||||
|
membership, and the context-menu "Get info" file inspector.
|
||||||
|
|
||||||
|
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||||
|
``meta_db`` -> ``appstate.meta_db``. DLC path resolution comes from
|
||||||
|
``dlc_paths``; sloppak/loose detection from the shared lib modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||||
|
import sloppak as sloppak_mod
|
||||||
|
import loosefolder as loosefolder_mod
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/chart/{filename:path}/split")
|
||||||
|
def api_split_chart(filename: str):
|
||||||
|
"""'These aren't the same song' — split this chart out as its own singleton
|
||||||
|
work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path}
|
||||||
|
catch-all can't shadow it."""
|
||||||
|
key = appstate.meta_db._canonical_song_filename(filename)
|
||||||
|
appstate.meta_db.split_chart(key)
|
||||||
|
return {"ok": True, "filename": key}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/chart/{filename:path}/unsplit")
|
||||||
|
def api_unsplit_chart(filename: str):
|
||||||
|
"""Undo a split — rejoin the chart to its work."""
|
||||||
|
key = appstate.meta_db._canonical_song_filename(filename)
|
||||||
|
appstate.meta_db.unsplit_chart(key)
|
||||||
|
return {"ok": True, "filename": key}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/chart/{filename:path}/work")
|
||||||
|
def api_get_chart_work(filename: str):
|
||||||
|
"""Resolve a chart's work membership: {work_key, chart_count}. For openers
|
||||||
|
on rows that came from an ungrouped query (the tree view) — grouped grid
|
||||||
|
rows already carry both fields inline."""
|
||||||
|
return appstate.meta_db.chart_work(filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/chart/{filename:path}/fileinfo")
|
||||||
|
def api_chart_fileinfo(filename: str):
|
||||||
|
"""The context menu's "Get info": where the file lives + what the pack
|
||||||
|
contains. Under /api/chart — the GET /api/song/{path} catch-all would
|
||||||
|
swallow a /api/song/…/fileinfo suffix. Read-only; demo-mode blocks it
|
||||||
|
because it exposes filesystem paths."""
|
||||||
|
dlc = _get_dlc_dir()
|
||||||
|
if not dlc:
|
||||||
|
raise HTTPException(status_code=404, detail="not configured")
|
||||||
|
p = _resolve_dlc_path(dlc, filename)
|
||||||
|
if p is None:
|
||||||
|
raise HTTPException(status_code=403, detail="forbidden")
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
# Restrict to actual charts — sloppak or loose song. Without this the route
|
||||||
|
# would stat ANY file the user happens to keep under DLC_DIR (e.g. notes),
|
||||||
|
# leaking its path/size; the app only recognises these two song formats.
|
||||||
|
is_pak = sloppak_mod.is_sloppak(p)
|
||||||
|
is_loose = loosefolder_mod.is_loose_song(p)
|
||||||
|
if not (is_pak or is_loose):
|
||||||
|
raise HTTPException(status_code=404, detail="not a chart")
|
||||||
|
st = p.stat()
|
||||||
|
info = {
|
||||||
|
"filename": filename,
|
||||||
|
"path": str(p),
|
||||||
|
"folder": str(p.parent),
|
||||||
|
"format": "sloppak" if is_pak else "loose",
|
||||||
|
# Directory-form songs report the tree's total (covers loose folders
|
||||||
|
# and dir-form paks); zip-form paks report the archive size. Symlinked
|
||||||
|
# entries are skipped so a link inside the folder can't pull in — or
|
||||||
|
# leak the size of — a file outside it.
|
||||||
|
"size": (st.st_size if p.is_file()
|
||||||
|
else sum(f.stat().st_size for f in p.rglob("*")
|
||||||
|
if f.is_file() and not f.is_symlink())),
|
||||||
|
"mtime": st.st_mtime,
|
||||||
|
}
|
||||||
|
if is_pak:
|
||||||
|
try:
|
||||||
|
m = sloppak_mod.load_manifest(p) or {}
|
||||||
|
except Exception:
|
||||||
|
m = {}
|
||||||
|
arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or [])
|
||||||
|
if isinstance(a, dict)]
|
||||||
|
stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)]
|
||||||
|
try:
|
||||||
|
has_cover = sloppak_mod.read_cover_bytes(p, m) is not None
|
||||||
|
except Exception:
|
||||||
|
has_cover = False
|
||||||
|
# The optional identity/catalog keys, listed only when present — the
|
||||||
|
# Get-info panel's "what this pack carries vs what's missing" readout.
|
||||||
|
identity = {k: m.get(k) for k in
|
||||||
|
("mbid", "isrc", "genres", "track", "disc", "album_artist",
|
||||||
|
"feedpak_version", "language")
|
||||||
|
if m.get(k) not in (None, "", [])}
|
||||||
|
info["manifest"] = {
|
||||||
|
"title": str(m.get("title", "")), "artist": str(m.get("artist", "")),
|
||||||
|
"album": str(m.get("album", "")), "year": str(m.get("year", "") or ""),
|
||||||
|
"arrangements": arrs, "stems": stems,
|
||||||
|
"has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")),
|
||||||
|
"authors": [a.get("name", "") if isinstance(a, dict) else str(a)
|
||||||
|
for a in (m.get("authors") or [])],
|
||||||
|
"identity": identity,
|
||||||
|
}
|
||||||
|
# The enrichment verdict, so Get info can say "Matched (auto, 96%)" /
|
||||||
|
# "Pinned by you" / "Not matched" alongside the file facts.
|
||||||
|
row = appstate.meta_db.get_enrichment(filename)
|
||||||
|
if row:
|
||||||
|
info["match"] = {k: row.get(k) for k in
|
||||||
|
("match_state", "match_source", "match_score",
|
||||||
|
"canon_artist", "canon_title", "canon_album", "canon_year")}
|
||||||
|
return info
|
||||||
@@ -55,7 +55,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
|||||||
# Lives in lib/ because that is the one core dir every packaging path copies.
|
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||||
import appstate
|
import appstate
|
||||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway
|
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart
|
||||||
import sloppak as sloppak_mod
|
import sloppak as sloppak_mod
|
||||||
import loosefolder as loosefolder_mod
|
import loosefolder as loosefolder_mod
|
||||||
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
||||||
@@ -4256,103 +4256,9 @@ def api_reset_work_preferred(work_key: str):
|
|||||||
return meta_db.work_charts(work_key)
|
return meta_db.work_charts(work_key)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chart/{filename:path}/split")
|
# ── Chart-level endpoints (split/work/fileinfo) ──────────────────────────────
|
||||||
def api_split_chart(filename: str):
|
# Mounted here (registration order). Implementation in lib/routers/chart.py.
|
||||||
"""'These aren't the same song' — split this chart out as its own singleton
|
app.include_router(chart.router)
|
||||||
work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path}
|
|
||||||
catch-all can't shadow it."""
|
|
||||||
key = meta_db._canonical_song_filename(filename)
|
|
||||||
meta_db.split_chart(key)
|
|
||||||
return {"ok": True, "filename": key}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chart/{filename:path}/unsplit")
|
|
||||||
def api_unsplit_chart(filename: str):
|
|
||||||
"""Undo a split — rejoin the chart to its work."""
|
|
||||||
key = meta_db._canonical_song_filename(filename)
|
|
||||||
meta_db.unsplit_chart(key)
|
|
||||||
return {"ok": True, "filename": key}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/chart/{filename:path}/work")
|
|
||||||
def api_get_chart_work(filename: str):
|
|
||||||
"""Resolve a chart's work membership: {work_key, chart_count}. For openers
|
|
||||||
on rows that came from an ungrouped query (the tree view) — grouped grid
|
|
||||||
rows already carry both fields inline."""
|
|
||||||
return meta_db.chart_work(filename)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/chart/{filename:path}/fileinfo")
|
|
||||||
def api_chart_fileinfo(filename: str):
|
|
||||||
"""The context menu's "Get info": where the file lives + what the pack
|
|
||||||
contains. Under /api/chart — the GET /api/song/{path} catch-all would
|
|
||||||
swallow a /api/song/…/fileinfo suffix. Read-only; demo-mode blocks it
|
|
||||||
because it exposes filesystem paths."""
|
|
||||||
dlc = _get_dlc_dir()
|
|
||||||
if not dlc:
|
|
||||||
raise HTTPException(status_code=404, detail="not configured")
|
|
||||||
p = _resolve_dlc_path(dlc, filename)
|
|
||||||
if p is None:
|
|
||||||
raise HTTPException(status_code=403, detail="forbidden")
|
|
||||||
if not p.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
|
||||||
# Restrict to actual charts — sloppak or loose song. Without this the route
|
|
||||||
# would stat ANY file the user happens to keep under DLC_DIR (e.g. notes),
|
|
||||||
# leaking its path/size; the app only recognises these two song formats.
|
|
||||||
is_pak = sloppak_mod.is_sloppak(p)
|
|
||||||
is_loose = loosefolder_mod.is_loose_song(p)
|
|
||||||
if not (is_pak or is_loose):
|
|
||||||
raise HTTPException(status_code=404, detail="not a chart")
|
|
||||||
st = p.stat()
|
|
||||||
info = {
|
|
||||||
"filename": filename,
|
|
||||||
"path": str(p),
|
|
||||||
"folder": str(p.parent),
|
|
||||||
"format": "sloppak" if is_pak else "loose",
|
|
||||||
# Directory-form songs report the tree's total (covers loose folders
|
|
||||||
# and dir-form paks); zip-form paks report the archive size. Symlinked
|
|
||||||
# entries are skipped so a link inside the folder can't pull in — or
|
|
||||||
# leak the size of — a file outside it.
|
|
||||||
"size": (st.st_size if p.is_file()
|
|
||||||
else sum(f.stat().st_size for f in p.rglob("*")
|
|
||||||
if f.is_file() and not f.is_symlink())),
|
|
||||||
"mtime": st.st_mtime,
|
|
||||||
}
|
|
||||||
if is_pak:
|
|
||||||
try:
|
|
||||||
m = sloppak_mod.load_manifest(p) or {}
|
|
||||||
except Exception:
|
|
||||||
m = {}
|
|
||||||
arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or [])
|
|
||||||
if isinstance(a, dict)]
|
|
||||||
stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)]
|
|
||||||
try:
|
|
||||||
has_cover = sloppak_mod.read_cover_bytes(p, m) is not None
|
|
||||||
except Exception:
|
|
||||||
has_cover = False
|
|
||||||
# The optional identity/catalog keys, listed only when present — the
|
|
||||||
# Get-info panel's "what this pack carries vs what's missing" readout.
|
|
||||||
identity = {k: m.get(k) for k in
|
|
||||||
("mbid", "isrc", "genres", "track", "disc", "album_artist",
|
|
||||||
"feedpak_version", "language")
|
|
||||||
if m.get(k) not in (None, "", [])}
|
|
||||||
info["manifest"] = {
|
|
||||||
"title": str(m.get("title", "")), "artist": str(m.get("artist", "")),
|
|
||||||
"album": str(m.get("album", "")), "year": str(m.get("year", "") or ""),
|
|
||||||
"arrangements": arrs, "stems": stems,
|
|
||||||
"has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")),
|
|
||||||
"authors": [a.get("name", "") if isinstance(a, dict) else str(a)
|
|
||||||
for a in (m.get("authors") or [])],
|
|
||||||
"identity": identity,
|
|
||||||
}
|
|
||||||
# The enrichment verdict, so Get info can say "Matched (auto, 96%)" /
|
|
||||||
# "Pinned by you" / "Not matched" alongside the file facts.
|
|
||||||
row = meta_db.get_enrichment(filename)
|
|
||||||
if row:
|
|
||||||
info["match"] = {k: row.get(k) for k in
|
|
||||||
("match_state", "match_source", "match_score",
|
|
||||||
"canon_artist", "canon_title", "canon_album", "canon_year")}
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/library/albums")
|
@app.get("/api/library/albums")
|
||||||
|
|||||||
Reference in New Issue
Block a user