mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 16:48:32 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f385fbdd54 | ||
|
|
b7624b7e65 | ||
|
|
f00ba2217d | ||
|
|
f09c4a217f | ||
|
|
9a58a55fe8 | ||
|
|
bbdff4e10f |
+1
-1
@@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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`.
|
- **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 `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/` — 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 `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
|
- **`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`
|
||||||
(3,692 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
(2,507 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||||
extractions and eighteen `routers/` modules (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
|
extractions and twenty-one `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
|
||||||
`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`
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ audio_effect_mappings = None
|
|||||||
# stable object mutated in place via register()/unregister() — injected here by
|
# stable object mutated in place via register()/unregister() — injected here by
|
||||||
# reference so routers read the same registry plugins populate.
|
# reference so routers read the same registry plugins populate.
|
||||||
tuning_providers = None
|
tuning_providers = None
|
||||||
|
# The library-provider registry instance + the local provider, constructed in
|
||||||
|
# server.py (LocalLibraryProvider needs meta_db) and injected by reference. The
|
||||||
|
# classes live in lib/library_registry.py; plugins register their own providers
|
||||||
|
# through the registry via plugin_context.
|
||||||
|
library_providers = None
|
||||||
|
local_library_provider = None
|
||||||
|
|
||||||
# Config paths. server.py derives these from the environment (fresh on every
|
# Config paths. server.py derives these from the environment (fresh on every
|
||||||
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
|
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
|
||||||
@@ -113,6 +119,7 @@ scan_status = None
|
|||||||
|
|
||||||
_SLOTS = frozenset({
|
_SLOTS = frozenset({
|
||||||
"meta_db", "audio_effect_mappings", "tuning_providers",
|
"meta_db", "audio_effect_mappings", "tuning_providers",
|
||||||
|
"library_providers", "local_library_provider",
|
||||||
"config_dir", "dlc_dir", "dlc_dir_env",
|
"config_dir", "dlc_dir", "dlc_dir_env",
|
||||||
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
||||||
"get_progression_content", "builtin_diagnostic_filename",
|
"get_progression_content", "builtin_diagnostic_filename",
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""The library-provider registry — the plugin extension point for song sources.
|
||||||
|
|
||||||
|
`LocalLibraryProvider` wraps the local `MetadataDB`; third-party plugins register
|
||||||
|
their own providers (duck-typed: any object with the advertised methods) through
|
||||||
|
`LibraryProviderRegistry`, and smart collections are surfaced as
|
||||||
|
`SmartCollectionProvider`s over the local one. server.py constructs the singleton
|
||||||
|
(`library_providers`), injects it + the local provider into appstate, and exposes
|
||||||
|
`register_library_provider`/`unregister_library_provider` to plugins via
|
||||||
|
plugin_context (with per-plugin ownership scoping in plugins/__init__.py).
|
||||||
|
|
||||||
|
Moved verbatim out of server.py (R3). The shared query/collection helpers live
|
||||||
|
here too so routers/library.py can import them without reaching into server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
from metadata_db import MetadataDB, _tuning_group_key_sql
|
||||||
|
from routers import art as art_router
|
||||||
|
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger("feedBack.server")
|
||||||
|
|
||||||
|
def _safe_art_redirect_url(url: str) -> str | None:
|
||||||
|
"""Return the URL if it is safe to redirect to (http/https only), else None."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme.lower() not in ("http", "https"):
|
||||||
|
return None
|
||||||
|
if not parsed.hostname:
|
||||||
|
return None
|
||||||
|
return url
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalLibraryProvider:
|
||||||
|
id = "local"
|
||||||
|
label = "My Library"
|
||||||
|
kind = "local"
|
||||||
|
capabilities = (
|
||||||
|
"library.read",
|
||||||
|
"art.read",
|
||||||
|
"song.play",
|
||||||
|
"favorite.write",
|
||||||
|
"metadata.write",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, db: MetadataDB):
|
||||||
|
self._db = db
|
||||||
|
|
||||||
|
def query_page(self, **kwargs) -> tuple[list[dict], int]:
|
||||||
|
return self._db.query_page(**kwargs)
|
||||||
|
|
||||||
|
def query_artists(self, **kwargs) -> tuple[list[dict], int]:
|
||||||
|
return self._db.query_artists(**kwargs)
|
||||||
|
|
||||||
|
def query_albums(self, **kwargs) -> tuple[list[dict], int]:
|
||||||
|
return self._db.query_albums(**kwargs)
|
||||||
|
|
||||||
|
def query_stats(self, **kwargs) -> dict:
|
||||||
|
return self._db.query_stats(**kwargs)
|
||||||
|
|
||||||
|
def tuning_names(self) -> dict:
|
||||||
|
# Group custom tunings on their raw offsets so distinct ones stay
|
||||||
|
# distinct (tuning_name collapses them all to "Custom Tuning"); named
|
||||||
|
# tunings keep grouping by name (stable across the rescan boundary, no
|
||||||
|
# offsets/name split). `key` is the value the client sends back as the
|
||||||
|
# filter selector — equal to the name for named tunings, the offsets
|
||||||
|
# string for customs; offsets also feed the client's custom-pill label.
|
||||||
|
with self._db._lock:
|
||||||
|
rows = self._db.conn.execute(
|
||||||
|
f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
|
||||||
|
"MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
|
||||||
|
"FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
|
||||||
|
"GROUP BY gkey COLLATE NOCASE "
|
||||||
|
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
|
||||||
|
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
|
||||||
|
"tuning_name COLLATE NOCASE"
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"tunings": [
|
||||||
|
{"name": name, "key": gkey, "offsets": offs or "",
|
||||||
|
"sort_key": int(sk or 0), "count": count}
|
||||||
|
for name, gkey, sk, count, offs in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_art(self, song_id: str):
|
||||||
|
return await art_router.get_song_art(song_id)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryProviderRegistry:
|
||||||
|
# Methods required per declared capability — only validated when the
|
||||||
|
# provider advertises the corresponding capability so action-only providers
|
||||||
|
# (e.g. art.read + song.sync without library.read) don't need to implement
|
||||||
|
# unused stubs.
|
||||||
|
_CAPABILITY_METHODS: ClassVar[dict[str, tuple[str, ...]]] = {
|
||||||
|
"library.read": ("query_page", "query_artists", "query_stats", "tuning_names"),
|
||||||
|
"art.read": ("get_art",),
|
||||||
|
"song.sync": ("sync_song",),
|
||||||
|
}
|
||||||
|
_ID_RE: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._providers: dict[str, object] = {}
|
||||||
|
# Capabilities inferred at registration for legacy providers that omit
|
||||||
|
# the `capabilities` field. Merged with provider_capabilities() so that
|
||||||
|
# runtime capability checks see the complete effective capability set.
|
||||||
|
self._inferred_caps: dict[str, set[str]] = {}
|
||||||
|
self._owner_plugin_ids: dict[str, str] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def register(self, provider: object, *, replace: bool = False, owner_plugin_id: str | None = None) -> object:
|
||||||
|
provider_id = self.provider_id(provider)
|
||||||
|
if not self._ID_RE.match(provider_id):
|
||||||
|
raise ValueError(
|
||||||
|
"library provider id must start with an alphanumeric character "
|
||||||
|
"and contain only letters, digits, _, ., :, or -"
|
||||||
|
)
|
||||||
|
if not self.provider_label(provider):
|
||||||
|
raise ValueError("library provider label must be a non-empty string")
|
||||||
|
# Use declared-only caps during validation — never include stale inferred
|
||||||
|
# caps from a previous provider registered under the same id (replace=True).
|
||||||
|
caps = self._declared_capabilities(provider)
|
||||||
|
# Backward compatibility: providers that predate explicit capability
|
||||||
|
# declarations may omit `capabilities` entirely. If the browse methods
|
||||||
|
# are all present, infer `library.read` so they still work unchanged.
|
||||||
|
# If capabilities are absent but the browse surface is also absent,
|
||||||
|
# raise a clear error rather than letting the provider register and
|
||||||
|
# then fail on every API call with a late 501.
|
||||||
|
inferred: set[str] = set()
|
||||||
|
if not caps:
|
||||||
|
browse_methods = self._CAPABILITY_METHODS["library.read"]
|
||||||
|
if all(callable(self.provider_method(provider, m)) for m in browse_methods):
|
||||||
|
# Legacy provider without explicit capabilities — infer library.read
|
||||||
|
# from the presence of all browse methods. Store in _inferred_caps
|
||||||
|
# so that runtime capability checks see the full effective set.
|
||||||
|
inferred = {"library.read"}
|
||||||
|
caps = inferred
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"library provider {provider_id!r} must declare at least one capability "
|
||||||
|
f"(or implement the {browse_methods!r} browse methods for backward compatibility)"
|
||||||
|
)
|
||||||
|
for cap, methods in self._CAPABILITY_METHODS.items():
|
||||||
|
if cap not in caps:
|
||||||
|
continue
|
||||||
|
for method_name in methods:
|
||||||
|
if not callable(self.provider_method(provider, method_name)):
|
||||||
|
raise TypeError(f"library provider {provider_id!r} declares {cap!r} but is missing callable {method_name}()")
|
||||||
|
with self._lock:
|
||||||
|
if provider_id == "local" and provider_id in self._providers and self._providers[provider_id] is not provider:
|
||||||
|
raise ValueError("the local library provider cannot be replaced")
|
||||||
|
if provider_id in self._providers and not replace:
|
||||||
|
raise ValueError(f"library provider {provider_id!r} is already registered")
|
||||||
|
self._providers[provider_id] = provider
|
||||||
|
# owner_plugin_id is attribution that flows into the browser
|
||||||
|
# capability participant id. The scoped register_library_provider
|
||||||
|
# wrappers force it to the trusted loading plugin id, so the spoof
|
||||||
|
# vector is closed there. Here we only normalize: trim and require a
|
||||||
|
# non-empty string. We deliberately do NOT apply the provider-id
|
||||||
|
# grammar (_ID_RE) — plugin ids aren't constrained to it at load
|
||||||
|
# time, so that would silently drop attribution for valid plugins.
|
||||||
|
owner = owner_plugin_id.strip() if isinstance(owner_plugin_id, str) else ""
|
||||||
|
owner = owner or None
|
||||||
|
if owner:
|
||||||
|
self._owner_plugin_ids[provider_id] = owner
|
||||||
|
else:
|
||||||
|
self._owner_plugin_ids.pop(provider_id, None)
|
||||||
|
if inferred:
|
||||||
|
self._inferred_caps[provider_id] = inferred
|
||||||
|
else:
|
||||||
|
self._inferred_caps.pop(provider_id, None)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
def unregister(self, provider_id: str) -> bool:
|
||||||
|
if provider_id == "local":
|
||||||
|
raise ValueError("the local library provider cannot be unregistered")
|
||||||
|
with self._lock:
|
||||||
|
self._inferred_caps.pop(provider_id, None)
|
||||||
|
self._owner_plugin_ids.pop(provider_id, None)
|
||||||
|
return self._providers.pop(provider_id, None) is not None
|
||||||
|
|
||||||
|
def get(self, provider_id: str = "local") -> object | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._providers.get(provider_id or "local")
|
||||||
|
|
||||||
|
def list(self) -> list[dict]:
|
||||||
|
with self._lock:
|
||||||
|
providers = list(self._providers.values())
|
||||||
|
return [self.describe(provider) for provider in providers]
|
||||||
|
|
||||||
|
def describe(self, provider: object) -> dict:
|
||||||
|
provider_id = self.provider_id(provider)
|
||||||
|
with self._lock:
|
||||||
|
owner_plugin_id = self._owner_plugin_ids.get(provider_id)
|
||||||
|
return {
|
||||||
|
"id": provider_id,
|
||||||
|
"label": self.provider_label(provider),
|
||||||
|
"kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"),
|
||||||
|
"capabilities": sorted(self.provider_capabilities(provider)),
|
||||||
|
"owner_plugin_id": owner_plugin_id,
|
||||||
|
"default": provider_id == "local",
|
||||||
|
}
|
||||||
|
|
||||||
|
def provider_field(self, provider: object, name: str, default=None):
|
||||||
|
if isinstance(provider, dict):
|
||||||
|
return provider.get(name, default)
|
||||||
|
return getattr(provider, name, default)
|
||||||
|
|
||||||
|
def provider_id(self, provider: object) -> str:
|
||||||
|
provider_id = self.provider_field(provider, "id", "")
|
||||||
|
if not isinstance(provider_id, str) or not provider_id:
|
||||||
|
raise ValueError("library provider id must be a non-empty string")
|
||||||
|
return provider_id
|
||||||
|
|
||||||
|
def provider_label(self, provider: object) -> str:
|
||||||
|
label = self.provider_field(provider, "label", self.provider_field(provider, "name", ""))
|
||||||
|
if not isinstance(label, str):
|
||||||
|
return ""
|
||||||
|
return label.strip()
|
||||||
|
|
||||||
|
def _declared_capabilities(self, provider: object) -> set[str]:
|
||||||
|
"""Return only the capabilities explicitly declared on the provider object."""
|
||||||
|
raw = self.provider_field(provider, "capabilities", ())
|
||||||
|
if raw is None:
|
||||||
|
raw = ()
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = (raw,) if raw else ()
|
||||||
|
return {str(cap) for cap in raw if cap}
|
||||||
|
|
||||||
|
def provider_capabilities(self, provider: object) -> set[str]:
|
||||||
|
# Guard against a common plugin authoring mistake: passing a single string
|
||||||
|
# instead of a list/tuple. Iterating a string produces individual characters,
|
||||||
|
# none of which would match a valid capability name.
|
||||||
|
declared = self._declared_capabilities(provider)
|
||||||
|
# Merge with any capabilities inferred at registration time for legacy
|
||||||
|
# providers that omit the `capabilities` field but implement browse methods.
|
||||||
|
provider_id = self.provider_id(provider)
|
||||||
|
with self._lock:
|
||||||
|
inferred = self._inferred_caps.get(provider_id, set())
|
||||||
|
return declared | inferred
|
||||||
|
|
||||||
|
def provider_method(self, provider: object, name: str):
|
||||||
|
if isinstance(provider, dict):
|
||||||
|
return provider.get(name)
|
||||||
|
return getattr(provider, name, None)
|
||||||
|
|
||||||
|
|
||||||
|
# Keys `_library_filter_args` (and a smart collection's stored `rules`) accept.
|
||||||
|
_LIBRARY_FILTER_PARAM_KEYS = frozenset((
|
||||||
|
"q", "favorites", "format", "artist", "album",
|
||||||
|
"arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks",
|
||||||
|
"has_lyrics", "tunings",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# Rules mirror the raw /api/library query params (so the provider can feed them
|
||||||
|
# straight through `_library_filter_args`, and the frontend can build a rule from
|
||||||
|
# the same query string it already constructs). Multi-value filters are CSV
|
||||||
|
# strings; `favorites` is 0/1; the rest are plain strings.
|
||||||
|
_RULE_CSV_KEYS = frozenset((
|
||||||
|
"tunings", "arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
_RULE_STR_KEYS = frozenset(("q", "format", "artist", "album", "has_lyrics", "sort"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_collection_rules(raw) -> dict:
|
||||||
|
"""Normalize rules to the raw query-param format, keeping only known keys. A
|
||||||
|
list for a multi-value filter is joined to CSV; `favorites` becomes 0/1.
|
||||||
|
Unknown keys are dropped so a rule survives a filter-vocab change rather than
|
||||||
|
500-ing. Applied at API ingress AND when a provider loads a persisted row, so
|
||||||
|
a hand-edited / imported bad value (e.g. an int where a string is expected,
|
||||||
|
or a list for `sort`) can never crash a query."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
out: dict = {}
|
||||||
|
for k, v in raw.items():
|
||||||
|
if k in _RULE_CSV_KEYS:
|
||||||
|
if isinstance(v, list):
|
||||||
|
vals = [str(x) for x in v if isinstance(x, (str, int)) and not isinstance(x, bool)]
|
||||||
|
elif isinstance(v, str):
|
||||||
|
vals = [s for s in (p.strip() for p in v.split(",")) if s]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if vals:
|
||||||
|
out[k] = ",".join(vals)
|
||||||
|
elif k == "favorites":
|
||||||
|
if v:
|
||||||
|
out[k] = 1
|
||||||
|
elif k in _RULE_STR_KEYS:
|
||||||
|
if isinstance(v, (str, int)) and not isinstance(v, bool):
|
||||||
|
s = str(v).strip()
|
||||||
|
if s:
|
||||||
|
out[k] = s
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class SmartCollectionProvider:
|
||||||
|
"""A saved library filter, surfaced as a source (#636 item 2). Browse/stats
|
||||||
|
delegate to the local DB with the collection's stored `rules` applied — so
|
||||||
|
selecting it in the v3 source picker shows exactly that filtered slice with
|
||||||
|
the whole Songs UI (paging, stats, A–Z rail, art) for free. P1: the rules
|
||||||
|
ARE the query (live in-collection search is a P2 nicety). The matched songs
|
||||||
|
are local rows, so `kind="local"` keeps the client's play/art paths on the
|
||||||
|
local (not remote-sync) branch and art delegates straight through."""
|
||||||
|
kind = "local"
|
||||||
|
capabilities = ("library.read", "art.read")
|
||||||
|
|
||||||
|
def __init__(self, collection: dict, local: "LocalLibraryProvider"):
|
||||||
|
self._local = local
|
||||||
|
self.update(collection)
|
||||||
|
|
||||||
|
def update(self, collection: dict) -> None:
|
||||||
|
self.id = f"collection:{collection['id']}"
|
||||||
|
self.collection_id = collection["id"]
|
||||||
|
self.label = collection.get("name") or "Collection"
|
||||||
|
# Re-sanitize on load: persisted JSON may predate the current vocab or
|
||||||
|
# have been hand-edited; never let a bad value reach a query.
|
||||||
|
self._rules = _sanitize_collection_rules(collection.get("rules") or {})
|
||||||
|
|
||||||
|
def _filter_kwargs(self) -> dict:
|
||||||
|
return _library_filter_args(**{k: v for k, v in self._rules.items()
|
||||||
|
if k in _LIBRARY_FILTER_PARAM_KEYS})
|
||||||
|
|
||||||
|
def _sort(self, fallback: str) -> str:
|
||||||
|
# A collection may pin its own sort (e.g. "recently added"); query_page
|
||||||
|
# falls back safely for an unknown value, so no validation needed here.
|
||||||
|
return self._rules.get("sort") or fallback
|
||||||
|
|
||||||
|
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
|
||||||
|
naming_mode="legacy", **_ignore):
|
||||||
|
return self._local._db.query_page(
|
||||||
|
page=page, size=size, sort=self._sort(sort), direction=direction,
|
||||||
|
naming_mode=naming_mode, **self._filter_kwargs())
|
||||||
|
|
||||||
|
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
|
||||||
|
return self._local._db.query_artists(
|
||||||
|
letter=letter, page=page, size=size, naming_mode=naming_mode,
|
||||||
|
**self._filter_kwargs())
|
||||||
|
|
||||||
|
def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
|
||||||
|
return self._local._db.query_albums(
|
||||||
|
page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
|
||||||
|
|
||||||
|
def query_stats(self, *, sort="artist", want_sort_letters=False,
|
||||||
|
naming_mode="legacy", **_ignore):
|
||||||
|
return self._local._db.query_stats(
|
||||||
|
sort=self._sort(sort), want_sort_letters=want_sort_letters,
|
||||||
|
naming_mode=naming_mode, **self._filter_kwargs())
|
||||||
|
|
||||||
|
def tuning_names(self):
|
||||||
|
return self._local.tuning_names()
|
||||||
|
|
||||||
|
async def get_art(self, song_id: str):
|
||||||
|
return await self._local.get_art(song_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv(raw: str) -> list[str]:
|
||||||
|
"""Parse a comma-separated query-string list. Empty / whitespace-only
|
||||||
|
entries are dropped so `arrangements_has=` (no value) and
|
||||||
|
`arrangements_has=,` both mean 'no filter'."""
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [s.strip() for s in raw.split(",") if s.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_has_lyrics(raw: str) -> int | None:
|
||||||
|
"""Tri-state parse for has_lyrics. `1` → require, `0` → exclude,
|
||||||
|
anything else (including empty) → no filter."""
|
||||||
|
if raw == "1":
|
||||||
|
return 1
|
||||||
|
if raw == "0":
|
||||||
|
return 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
|
||||||
|
artist: str = "", album: str = "",
|
||||||
|
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||||
|
stems_has: str = "", stems_lacks: str = "",
|
||||||
|
has_lyrics: str = "", tunings: str = "") -> dict:
|
||||||
|
fmt = format if format in ("archive", "sloppak", "loose") else ""
|
||||||
|
return {
|
||||||
|
"q": q,
|
||||||
|
"favorites_only": bool(favorites),
|
||||||
|
"format_filter": fmt,
|
||||||
|
"artist_filter": (artist or "").strip(),
|
||||||
|
"album_filter": (album or "").strip(),
|
||||||
|
"arrangements_has": _split_csv(arrangements_has),
|
||||||
|
"arrangements_lacks": _split_csv(arrangements_lacks),
|
||||||
|
"stems_has": _split_csv(stems_has),
|
||||||
|
"stems_lacks": _split_csv(stems_lacks),
|
||||||
|
"has_lyrics": _parse_has_lyrics(has_lyrics),
|
||||||
|
"tunings": _split_csv(tunings),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_collection_provider(collection: dict) -> None:
|
||||||
|
"""Register (or replace) the provider for one collection."""
|
||||||
|
appstate.library_providers.register(
|
||||||
|
SmartCollectionProvider(collection, appstate.local_library_provider), replace=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _unregister_collection_provider(pid: int) -> None:
|
||||||
|
appstate.library_providers.unregister(f"collection:{pid}")
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""Metadata-enrichment route handlers (/api/enrichment/*): status, kick/cancel,
|
||||||
|
per-song state, the Match-Review queue (accept/reject/pick/search), and AcoustID
|
||||||
|
fingerprint identify.
|
||||||
|
|
||||||
|
Extracted verbatim from server.py (R3) except @app->@router and the seam reads
|
||||||
|
(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir). The enrichment
|
||||||
|
engine itself — transport, matcher, the background worker, and the upload caps —
|
||||||
|
lives in lib/enrichment.py and is reached here as enrichment.X.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
import enrichment
|
||||||
|
import mb_match
|
||||||
|
from appconfig import _load_config
|
||||||
|
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger("feedBack.server")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/api/enrichment/status")
|
||||||
|
def enrichment_status():
|
||||||
|
"""Enrichment pipeline state: worker flags + row counts by match_state.
|
||||||
|
Ambient tool-state for the match-review UI (never a home-screen score —
|
||||||
|
design §11); also what tests poke."""
|
||||||
|
return {
|
||||||
|
"running": enrichment._enrich_status["running"],
|
||||||
|
"processed": enrichment._enrich_status["processed"],
|
||||||
|
"last_pass_at": enrichment._enrich_status["last_pass_at"],
|
||||||
|
"states": appstate.meta_db.enrichment_state_counts(),
|
||||||
|
"total_songs": appstate.meta_db.count(),
|
||||||
|
# Per-pass matching progress for the "Refresh Metadata" batch bar +
|
||||||
|
# per-tile badges (total = songs queued to match this pass, matched =
|
||||||
|
# done so far, current = the one being matched now).
|
||||||
|
"total": enrichment._enrich_status.get("total", 0),
|
||||||
|
"matched": enrichment._enrich_status.get("matched", 0),
|
||||||
|
"current": enrichment._enrich_status.get("current"),
|
||||||
|
"cancelling": enrichment._enrich_cancel.is_set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/enrichment/song/{filename:path}")
|
||||||
|
def api_enrichment_song(filename: str):
|
||||||
|
"""Read-only per-song match provenance for the Details drawer (launch
|
||||||
|
polish): which canonical identity this chart matched and how. A tiny
|
||||||
|
projection of the cache row — no candidates, no cache paths."""
|
||||||
|
row = appstate.meta_db.get_enrichment(filename)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="no enrichment row")
|
||||||
|
return {k: row.get(k) for k in
|
||||||
|
("match_state", "canon_artist", "canon_title",
|
||||||
|
"match_source", "match_score")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/kick")
|
||||||
|
def api_enrichment_kick():
|
||||||
|
"""The Settings "Match now" button AND the library's "Refresh Metadata"
|
||||||
|
button: request an enrichment pass without waiting for a scan to complete.
|
||||||
|
Processes the songs that still need it (unscanned/changed + retriable
|
||||||
|
failures) — already-matched songs are left alone, so on a fully-matched
|
||||||
|
library this is a fast no-op. Single-flight + coalescing like every other
|
||||||
|
kick — spamming it queues at most one follow-up pass."""
|
||||||
|
return {"started": enrichment._kick_enrich()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/cancel")
|
||||||
|
def api_enrichment_cancel():
|
||||||
|
"""Stop button on the "Refresh Metadata" batch: signal the running pass to
|
||||||
|
halt after the current song (an in-flight ≤1/s lookup can't be interrupted,
|
||||||
|
but no new one is started) and drop any coalesced follow-up. A no-op when
|
||||||
|
nothing is running."""
|
||||||
|
was_running = enrichment._enrich_status["running"]
|
||||||
|
if was_running:
|
||||||
|
enrichment._enrich_cancel.set()
|
||||||
|
return {"ok": True, "was_running": was_running}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/rematch")
|
||||||
|
def api_enrichment_rematch(data: dict = Body(...)):
|
||||||
|
"""The library "Refresh Metadata" button: force a fresh re-match of the
|
||||||
|
songs the grid is SHOWING (its visible/filtered window). Resets each to
|
||||||
|
`unscanned` so the next pass re-fetches it from scratch — EXCEPT user-pinned
|
||||||
|
`manual` rows, which are never auto-overwritten (apply_enrichment_match
|
||||||
|
guards that) — then kicks one pass. Scoped to the visible set on purpose:
|
||||||
|
fast (dozens of songs), visible (tiles animate), and it can't blow the whole
|
||||||
|
≤1/s rate budget on a 1000-song library the way a full re-sweep would.
|
||||||
|
Returns the filenames actually queued so the UI badges exactly those."""
|
||||||
|
raw = (data or {}).get("filenames") or []
|
||||||
|
fns = [str(f) for f in raw if isinstance(f, str)][:500]
|
||||||
|
queued: list[str] = []
|
||||||
|
for fn in fns:
|
||||||
|
song = appstate.meta_db.enrichment_song_row(fn)
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
h = appstate.meta_db.enrichment_content_hash(
|
||||||
|
song["artist"], song["title"], song["album"], song["duration"])
|
||||||
|
# allow_manual_overwrite=False → a manual pin is left as-is (returns
|
||||||
|
# False), everything else resets to unscanned (returns True).
|
||||||
|
if appstate.meta_db.apply_enrichment_match(fn, h, "unscanned",
|
||||||
|
allow_manual_overwrite=False):
|
||||||
|
queued.append(fn)
|
||||||
|
started = enrichment._kick_enrich() if queued else False
|
||||||
|
return {"queued": queued, "count": len(queued), "started": started}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/states")
|
||||||
|
def api_enrichment_states(data: dict = Body(...)):
|
||||||
|
"""Per-tile match states for the grid's VISIBLE window during a metadata
|
||||||
|
refresh: the client posts the filenames it is showing and gets back each
|
||||||
|
one's match_state (+ the song being matched right now, + whether a pass is
|
||||||
|
running), so a card can animate queued→working→result without a per-song
|
||||||
|
round-trip. Read-only — safe for demo visitors (no network, no mutation)."""
|
||||||
|
raw = (data or {}).get("filenames") or []
|
||||||
|
# Bound the batch: a visible grid window is dozens of cards; cap defensively.
|
||||||
|
fns = [str(f) for f in raw if isinstance(f, str)][:500]
|
||||||
|
return {
|
||||||
|
"states": appstate.meta_db.enrichment_states_for(fns),
|
||||||
|
"current": enrichment._enrich_status.get("current"),
|
||||||
|
"running": enrichment._enrich_status["running"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/refresh/{filename:path}")
|
||||||
|
def api_enrichment_refresh(filename: str):
|
||||||
|
"""The context menu's "Refresh metadata": reset THIS song's match to
|
||||||
|
unscanned (canonical values + candidates cleared, backoff zeroed) and
|
||||||
|
kick a pass so it re-matches immediately. An EXPLICIT user action, so it
|
||||||
|
may discard a manual pin — the automation never does, but the user
|
||||||
|
asking for a re-match is the one party who owns that pin."""
|
||||||
|
song = appstate.meta_db.enrichment_song_row(filename)
|
||||||
|
if not song:
|
||||||
|
raise HTTPException(status_code=404, detail="unknown song")
|
||||||
|
h = appstate.meta_db.enrichment_content_hash(
|
||||||
|
song["artist"], song["title"], song["album"], song["duration"])
|
||||||
|
appstate.meta_db.apply_enrichment_match(filename, h, "unscanned",
|
||||||
|
allow_manual_overwrite=True)
|
||||||
|
return {"ok": True, "started": enrichment._kick_enrich()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/enrichment/review")
|
||||||
|
def api_enrichment_review(limit: int = 200):
|
||||||
|
"""The Match-Review queue: songs whose text match landed in the medium-
|
||||||
|
confidence review tier, each with its stored candidate list — the drawer
|
||||||
|
renders straight from this, no MusicBrainz round-trip. Ordered by the
|
||||||
|
user's enrich_review_order setting."""
|
||||||
|
limit = max(1, min(int(limit), 500))
|
||||||
|
cfg = _load_config(appstate.config_dir / "config.json") or {}
|
||||||
|
order = cfg.get("enrich_review_order", "missing_first")
|
||||||
|
return {
|
||||||
|
"songs": appstate.meta_db.enrichment_review_queue(limit=limit, order=order),
|
||||||
|
"total_review": appstate.meta_db.enrichment_state_counts().get("review", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/review/{filename:path}/accept")
|
||||||
|
def api_enrichment_accept(filename: str, data: dict = Body(...)):
|
||||||
|
"""Accept one of the stored review candidates: the row becomes a
|
||||||
|
user-pinned `manual` match (never auto-reset). Display-only, like every
|
||||||
|
enrichment write — nothing touches the pack file."""
|
||||||
|
recording_id = str((data or {}).get("recording_id") or "")
|
||||||
|
row = appstate.meta_db.get_enrichment(filename)
|
||||||
|
if not row or row["match_state"] != "review":
|
||||||
|
raise HTTPException(status_code=404, detail="no review row for this song")
|
||||||
|
cand = next((c for c in (row.get("candidates") or [])
|
||||||
|
if c.get("recording_id") == recording_id), None)
|
||||||
|
if not cand:
|
||||||
|
raise HTTPException(status_code=404, detail="candidate not in the stored list")
|
||||||
|
if not appstate.meta_db.set_enrichment_manual(filename, cand, source="review"):
|
||||||
|
raise HTTPException(status_code=404, detail="unknown song")
|
||||||
|
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/review/{filename:path}/reject")
|
||||||
|
def api_enrichment_reject(filename: str):
|
||||||
|
""""None of these" — clears any canonical values and parks the row as
|
||||||
|
failed/rejected (never auto-retried; editing the song's metadata
|
||||||
|
re-queues it). Valid from `review` or `matched`, never from `manual`."""
|
||||||
|
if not appstate.meta_db.set_enrichment_rejected(filename):
|
||||||
|
raise HTTPException(status_code=404, detail="no rejectable match for this song")
|
||||||
|
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
|
||||||
|
|
||||||
|
|
||||||
|
# The candidate fields a manual pick is allowed to carry — the payload comes
|
||||||
|
# from our own /api/enrichment/search proxy, but the route re-sanitizes so a
|
||||||
|
# hand-rolled client can't stuff arbitrary keys/types into the cache row.
|
||||||
|
_CAND_STR_FIELDS = ("recording_id", "title", "artist", "artist_id",
|
||||||
|
"artist_sort", "release_id", "album", "year", "isrc")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_candidate(raw: dict) -> dict | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
out = {k: str(raw.get(k) or "") for k in _CAND_STR_FIELDS}
|
||||||
|
if not out["recording_id"] or not out["title"]:
|
||||||
|
return None
|
||||||
|
genres = raw.get("genres") or []
|
||||||
|
out["genres"] = [str(g) for g in genres if isinstance(g, str)][:5] \
|
||||||
|
if isinstance(genres, list) else []
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/review/{filename:path}/pick")
|
||||||
|
def api_enrichment_pick(filename: str, data: dict = Body(...)):
|
||||||
|
"""Fix-match / manual search-and-pick: pin a candidate the user found via
|
||||||
|
/api/enrichment/search (not limited to the stored review list — this is
|
||||||
|
the escape hatch for a wrong auto-match too). Sets `manual`, the
|
||||||
|
highest-authority state."""
|
||||||
|
cand = _sanitize_candidate((data or {}).get("candidate"))
|
||||||
|
if not cand:
|
||||||
|
raise HTTPException(status_code=400, detail="candidate needs recording_id + title")
|
||||||
|
if not appstate.meta_db.set_enrichment_manual(filename, cand, source="search"):
|
||||||
|
raise HTTPException(status_code=404, detail="unknown song")
|
||||||
|
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/enrichment/search")
|
||||||
|
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
||||||
|
filename: str = "", duration: float = 0.0):
|
||||||
|
"""Manual-search proxy to MusicBrainz (throttled + identified like the
|
||||||
|
background matcher — a user typing in the drawer must not sidestep the
|
||||||
|
rate limit). `filename` optionally scores results against that song's
|
||||||
|
stored identity (year/duration corroboration) instead of just the typed
|
||||||
|
text. `duration` (seconds) lets a caller that HAS the audio but no library
|
||||||
|
row — e.g. the editor's create modal, which holds the master track — pass
|
||||||
|
its length so the studio take ranks above live/extended cuts. Sync route on
|
||||||
|
purpose: FastAPI runs it in the threadpool, so the throttle's sleep never
|
||||||
|
blocks the event loop."""
|
||||||
|
if not (artist.strip() or title.strip()):
|
||||||
|
raise HTTPException(status_code=400, detail="artist or title required")
|
||||||
|
limit = max(1, min(int(limit), 25))
|
||||||
|
try:
|
||||||
|
cands = enrichment._mb_search_recordings(artist, title, limit=limit)
|
||||||
|
except enrichment.EnrichTransportError as e:
|
||||||
|
return JSONResponse({"error": "musicbrainz unavailable", "detail": str(e)},
|
||||||
|
status_code=503)
|
||||||
|
ref = None
|
||||||
|
if filename:
|
||||||
|
ref = appstate.meta_db.enrichment_song_row(filename)
|
||||||
|
if ref is None:
|
||||||
|
ref = {"artist": artist, "title": title}
|
||||||
|
# A caller-supplied duration corroborates the take even without a library row.
|
||||||
|
if duration and duration > 0 and not ref.get("duration"):
|
||||||
|
ref = dict(ref)
|
||||||
|
ref["duration"] = duration
|
||||||
|
# Alias-enrich so a non-Latin-primary artist (大橋純子) ranks by its
|
||||||
|
# romanized alias against the typed query ("Junko Ohashi") instead of
|
||||||
|
# sinking to the bottom with a 0 artist score.
|
||||||
|
try:
|
||||||
|
enrichment._alias_enrich(ref, cands)
|
||||||
|
except enrichment.EnrichTransportError:
|
||||||
|
pass # aliases are a ranking nicety here; fall back to primary-name scoring
|
||||||
|
return {"candidates": mb_match.rank_candidates(ref, cands)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/identify")
|
||||||
|
async def api_enrichment_identify(request: Request):
|
||||||
|
"""Identify a song by AUDIO FINGERPRINT (AcoustID) rather than text — the
|
||||||
|
reliable way to get the EXACT recording/version (the studio take, not a live
|
||||||
|
bootleg or an extended cut). Upload the master audio; returns candidates in
|
||||||
|
the same shape as /search, so the review UI and the editor's Match popup can
|
||||||
|
render fingerprint hits identically. 412 `needs_setup` when the user hasn't
|
||||||
|
opted in / has no key (the UI nudges them to Settings); 503 when it's set up
|
||||||
|
but the fpcalc Chromaprint binary is missing or the network is off. Async so
|
||||||
|
the multipart is size-capped BEFORE spooling; the blocking fpcalc subprocess
|
||||||
|
+ AcoustID HTTP run in the threadpool via run_in_executor."""
|
||||||
|
gate = enrichment._acoustid_gate()
|
||||||
|
if gate is not None:
|
||||||
|
return gate
|
||||||
|
# Pre-parse Content-Length guard — reject an oversized body before Starlette
|
||||||
|
# spools the multipart to temp disk (mirrors the song-upload endpoint). The
|
||||||
|
# per-part cap below is the authoritative limit; this is the fast up-front no.
|
||||||
|
cl = request.headers.get("content-length")
|
||||||
|
if cl is not None:
|
||||||
|
try:
|
||||||
|
cl_int = int(cl)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
|
||||||
|
if cl_int > enrichment._ACOUSTID_MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK:
|
||||||
|
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||||
|
try:
|
||||||
|
form = await request.form(max_part_size=enrichment._ACOUSTID_MAX_UPLOAD_BYTES)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||||
|
file = form.get("file")
|
||||||
|
if not isinstance(file, UploadFile):
|
||||||
|
raise HTTPException(status_code=400, detail="missing file upload")
|
||||||
|
import tempfile
|
||||||
|
ext = (Path(file.filename or "").suffix or ".bin").lower()
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_")
|
||||||
|
tmp = os.path.join(tmpdir, "audio" + ext)
|
||||||
|
try:
|
||||||
|
total = 0
|
||||||
|
with open(tmp, "wb") as fh:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > enrichment._ACOUSTID_MAX_UPLOAD_BYTES:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||||
|
fh.write(chunk)
|
||||||
|
if total == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="empty upload")
|
||||||
|
# fpcalc subprocess + AcoustID HTTP are blocking — off the event loop.
|
||||||
|
cands = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, enrichment._identify_by_fingerprint, tmp)
|
||||||
|
except enrichment.EnrichTransportError as e:
|
||||||
|
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
|
||||||
|
status_code=503)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
return {"candidates": cands}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/enrichment/identify/{filename:path}")
|
||||||
|
def api_enrichment_identify_song(filename: str):
|
||||||
|
"""Identify an EXISTING library song by AUDIO FINGERPRINT — the library-side
|
||||||
|
counterpart to /api/enrichment/identify (which takes an upload). Fingerprints
|
||||||
|
the song's own master audio on disk (the manual "Identify by audio" action in
|
||||||
|
the Fix-metadata / match-review flow). Same candidate shape as /search, so the
|
||||||
|
review UI renders fingerprint hits like text hits. Same 412/503 gating; 404
|
||||||
|
when the song has no full-mix audio to fingerprint."""
|
||||||
|
gate = enrichment._acoustid_gate()
|
||||||
|
if gate is not None:
|
||||||
|
return gate
|
||||||
|
audio = enrichment._song_audio_file(filename)
|
||||||
|
if not audio:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "no audio",
|
||||||
|
"detail": "couldn't find this song's master audio to fingerprint "
|
||||||
|
"(a stems-only pack has no full mix to identify)."},
|
||||||
|
status_code=404)
|
||||||
|
try:
|
||||||
|
cands = enrichment._identify_by_fingerprint(audio)
|
||||||
|
except enrichment.EnrichTransportError as e:
|
||||||
|
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
|
||||||
|
status_code=503)
|
||||||
|
return {"candidates": cands}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"""Library + smart-collection routes: the provider list/art/sync endpoints, the
|
||||||
|
library query surface (songs, albums, artists, stats, genres, tuning-names,
|
||||||
|
practice-suggestions), and collection CRUD.
|
||||||
|
|
||||||
|
Extracted verbatim from server.py (R3) except @app->@router and the seam reads:
|
||||||
|
meta_db->appstate.meta_db, and the registry singletons ->
|
||||||
|
appstate.library_providers / appstate.local_library_provider (constructed +
|
||||||
|
owned by server.py; plugins register providers through plugin_context). The
|
||||||
|
provider classes + shared query/collection helpers live in lib/library_registry.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
from library_registry import (
|
||||||
|
_library_filter_args, _sanitize_collection_rules,
|
||||||
|
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
|
||||||
|
_unregister_collection_provider,
|
||||||
|
)
|
||||||
|
from metadata_db import _effective_keyset_sort, next_library_cursor
|
||||||
|
from reqfields import _clean_str
|
||||||
|
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger("feedBack.server")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_library_provider(provider: str = "local") -> object:
|
||||||
|
library_provider = appstate.library_providers.get(provider or "local")
|
||||||
|
if library_provider is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown library provider: {provider}")
|
||||||
|
return library_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _require_library_provider_capability(provider: object, capability: str) -> None:
|
||||||
|
if capability in appstate.library_providers.provider_capabilities(provider):
|
||||||
|
return
|
||||||
|
provider_id = appstate.library_providers.provider_id(provider)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=501,
|
||||||
|
detail=f"Library provider {provider_id!r} does not declare capability {capability!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
|
||||||
|
"mastery", "match_states")
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
|
||||||
|
"""Drop kwargs that the method's signature does not declare.
|
||||||
|
|
||||||
|
Provides backward-compat for third-party library providers whose
|
||||||
|
query_page/query_artists/query_stats methods were written before
|
||||||
|
naming_mode was added — calling them with the extra kwarg would
|
||||||
|
raise TypeError and return a 500 to the client.
|
||||||
|
|
||||||
|
When ``inspect.signature`` cannot introspect the method (rare: C
|
||||||
|
extensions / built-ins / exotic callables), fall back to stripping
|
||||||
|
only the kwargs we know were added later — older providers won't
|
||||||
|
accept them, anything else stays so the call still works.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sig = inspect.signature(method) # type: ignore[arg-type]
|
||||||
|
for p in sig.parameters.values():
|
||||||
|
if p.kind == inspect.Parameter.VAR_KEYWORD:
|
||||||
|
return kwargs # method accepts **kwargs, pass everything
|
||||||
|
return {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {k: v for k, v in kwargs.items() if k not in _OPTIONAL_NEW_PROVIDER_KWARGS}
|
||||||
|
|
||||||
|
|
||||||
|
def _call_library_provider(provider: object, method_name: str, **kwargs) -> Any:
|
||||||
|
method = appstate.library_providers.provider_method(provider, method_name)
|
||||||
|
if not callable(method):
|
||||||
|
provider_id = appstate.library_providers.provider_id(provider)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=501,
|
||||||
|
detail=f"Library provider {provider_id!r} does not support {method_name}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return method(**_filter_provider_kwargs(method, kwargs))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
provider_id = appstate.library_providers.provider_id(provider)
|
||||||
|
# A provider with an explicit kind="local" is treated as local even if
|
||||||
|
# its id is not "local" (e.g. a kind="local" plugin variant). Otherwise
|
||||||
|
# fall back to provider_id comparison so providers that omit `kind` are
|
||||||
|
# still wrapped correctly — the safe default for unknown providers is to
|
||||||
|
# surface an offline message rather than leaking raw exceptions.
|
||||||
|
provider_kind = str(appstate.library_providers.provider_field(provider, "kind", "") or "")
|
||||||
|
if provider_kind:
|
||||||
|
is_remote = provider_kind not in ("", "local")
|
||||||
|
else:
|
||||||
|
is_remote = provider_id != "local"
|
||||||
|
if is_remote:
|
||||||
|
detail = f"This source appears to be offline ({provider_id})."
|
||||||
|
message = str(exc).strip()
|
||||||
|
if message:
|
||||||
|
detail = f"{detail} {message}"
|
||||||
|
raise HTTPException(status_code=503, detail=detail) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _is_async_callable(obj: object) -> bool:
|
||||||
|
"""Return True if obj is an async function or a callable object with an async __call__.
|
||||||
|
|
||||||
|
``inspect.iscoroutinefunction`` only recognises bare coroutine functions; it returns
|
||||||
|
False for class instances whose ``__call__`` method is defined as ``async def``.
|
||||||
|
Checking both handles the common plugin pattern of wrapping an async method in a
|
||||||
|
callable object.
|
||||||
|
"""
|
||||||
|
if inspect.iscoroutinefunction(obj):
|
||||||
|
return True
|
||||||
|
_call = getattr(obj, "__call__", None)
|
||||||
|
return _call is not None and inspect.iscoroutinefunction(_call)
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_library_provider_async(provider: object, method_name: str, **kwargs) -> Any:
|
||||||
|
method = appstate.library_providers.provider_method(provider, method_name)
|
||||||
|
if _is_async_callable(method):
|
||||||
|
# Async provider method — call directly on the event loop.
|
||||||
|
try:
|
||||||
|
return await method(**_filter_provider_kwargs(method, kwargs))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
provider_id = appstate.library_providers.provider_id(provider)
|
||||||
|
provider_kind = str(appstate.library_providers.provider_field(provider, "kind", "") or "")
|
||||||
|
if provider_kind:
|
||||||
|
is_remote = provider_kind not in ("", "local")
|
||||||
|
else:
|
||||||
|
is_remote = provider_id != "local"
|
||||||
|
if is_remote:
|
||||||
|
detail = f"This source appears to be offline ({provider_id})."
|
||||||
|
message = str(exc).strip()
|
||||||
|
if message:
|
||||||
|
detail = f"{detail} {message}"
|
||||||
|
raise HTTPException(status_code=503, detail=detail) from exc
|
||||||
|
raise
|
||||||
|
# Synchronous provider method — run in a threadpool so the event loop stays free.
|
||||||
|
return await run_in_threadpool(_call_library_provider, provider, method_name, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _library_art_response(result: Any) -> Response:
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Library provider returned no art")
|
||||||
|
if isinstance(result, Response):
|
||||||
|
return result
|
||||||
|
if isinstance(result, (bytes, bytearray, memoryview)):
|
||||||
|
return Response(content=bytes(result), media_type="image/png")
|
||||||
|
if isinstance(result, str):
|
||||||
|
safe_url = _safe_art_redirect_url(result)
|
||||||
|
if safe_url is not None:
|
||||||
|
return RedirectResponse(safe_url)
|
||||||
|
# If the string looks like a URL (contains a scheme separator) but
|
||||||
|
# didn't pass the http/https check, refuse it rather than treating
|
||||||
|
# it as a filesystem path — a provider returning ftp:// or file://
|
||||||
|
# should get a 400, not a 500 from FileResponse failing on a URL.
|
||||||
|
if "://" in result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Library provider returned an unsupported URL scheme for art",
|
||||||
|
)
|
||||||
|
if not Path(result).is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
|
||||||
|
return FileResponse(result)
|
||||||
|
if isinstance(result, Path):
|
||||||
|
if not result.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
|
||||||
|
return FileResponse(str(result))
|
||||||
|
if isinstance(result, dict):
|
||||||
|
url = result.get("url") or result.get("art_url") or result.get("artUrl")
|
||||||
|
if isinstance(url, str) and url:
|
||||||
|
safe_url = _safe_art_redirect_url(url)
|
||||||
|
if safe_url is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Library provider returned an unsafe art URL")
|
||||||
|
return RedirectResponse(safe_url)
|
||||||
|
path = result.get("path") or result.get("file")
|
||||||
|
if isinstance(path, (str, Path)):
|
||||||
|
media_type = result.get("media_type") or result.get("content_type")
|
||||||
|
if not Path(path).is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
|
||||||
|
return FileResponse(str(path), media_type=media_type)
|
||||||
|
content = result.get("content") or result.get("bytes")
|
||||||
|
if isinstance(content, (bytes, bytearray, memoryview)):
|
||||||
|
media_type = result.get("media_type") or result.get("content_type") or "image/png"
|
||||||
|
return Response(content=bytes(content), media_type=media_type)
|
||||||
|
raise HTTPException(status_code=500, detail="Library provider returned unsupported art data")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/providers")
|
||||||
|
def list_library_providers():
|
||||||
|
"""List registered library providers."""
|
||||||
|
return {"providers": appstate.library_providers.list()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/providers/{provider_id}/songs/{song_id:path}/art")
|
||||||
|
async def get_library_provider_song_art(provider_id: str, song_id: str):
|
||||||
|
"""Return album art for a song owned by a library provider."""
|
||||||
|
library_provider = _get_library_provider(provider_id)
|
||||||
|
_require_library_provider_capability(library_provider, "art.read")
|
||||||
|
result = await _call_library_provider_async(library_provider, "get_art", song_id=song_id)
|
||||||
|
return _library_art_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/library/providers/{provider_id}/songs/{song_id:path}/sync")
|
||||||
|
async def sync_library_provider_song(provider_id: str, song_id: str):
|
||||||
|
"""Ask a provider to sync a remote song into the local library/cache."""
|
||||||
|
library_provider = _get_library_provider(provider_id)
|
||||||
|
_require_library_provider_capability(library_provider, "song.sync")
|
||||||
|
result = await _call_library_provider_async(library_provider, "sync_song", song_id=song_id)
|
||||||
|
if result is None:
|
||||||
|
return {"ok": True}
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
return {"ok": True, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library")
|
||||||
|
async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "artist",
|
||||||
|
dir: str = "asc", favorites: int = 0, format: str = "",
|
||||||
|
artist: str = "", album: str = "",
|
||||||
|
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||||
|
stems_has: str = "", stems_lacks: str = "",
|
||||||
|
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||||
|
mastery: str = "", tags: str = "", user_difficulty: str = "",
|
||||||
|
match: str = "", genre: str = "", after: str = "", group: int = 0,
|
||||||
|
naming_mode: str = "legacy"):
|
||||||
|
"""Paginated library search through the selected library provider.
|
||||||
|
|
||||||
|
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
|
||||||
|
`next_cursor` from the previous response to fetch the next page with a
|
||||||
|
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
|
||||||
|
page by OFFSET, so the client can always fall back."""
|
||||||
|
size = min(size, 100)
|
||||||
|
library_provider = _get_library_provider(provider)
|
||||||
|
_require_library_provider_capability(library_provider, "library.read")
|
||||||
|
# Only the true local provider keysets: it's the one whose effective sort is
|
||||||
|
# exactly the request `sort`. A smart collection may pin its own sort and
|
||||||
|
# remote providers don't keyset — both must page by OFFSET, so never hand
|
||||||
|
# them a cursor (a mismatched one would mis-seek).
|
||||||
|
is_local = getattr(library_provider, "id", "") == "local"
|
||||||
|
songs, total = await _call_library_provider_async(
|
||||||
|
library_provider,
|
||||||
|
"query_page",
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
sort=sort,
|
||||||
|
direction=dir,
|
||||||
|
after=((after or None) if is_local else None),
|
||||||
|
group=bool(group),
|
||||||
|
naming_mode=naming_mode,
|
||||||
|
mastery=_split_csv(mastery),
|
||||||
|
tags_has=_split_csv(tags),
|
||||||
|
user_difficulty_in=_split_csv(user_difficulty),
|
||||||
|
match_states=_split_csv(match),
|
||||||
|
genre=_split_csv(genre),
|
||||||
|
**_library_filter_args(
|
||||||
|
q=q, favorites=favorites, format=format,
|
||||||
|
artist=artist, album=album,
|
||||||
|
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||||
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
|
has_lyrics=has_lyrics, tunings=tunings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# The cursor to resume after this page (effective sort folds in dir=desc).
|
||||||
|
next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1])
|
||||||
|
if (is_local and songs) else None)
|
||||||
|
# Drop the private raw-title stash query_page attached for the cursor — it's
|
||||||
|
# an internal keyset detail, not part of the card payload.
|
||||||
|
for s in songs:
|
||||||
|
s.pop("_sort_title", None)
|
||||||
|
return {"songs": songs, "total": total, "page": page, "size": size,
|
||||||
|
"next_cursor": next_cursor}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/albums")
|
||||||
|
async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
|
||||||
|
favorites: int = 0, format: str = "",
|
||||||
|
artist: str = "", album: str = "",
|
||||||
|
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||||
|
stems_has: str = "", stems_lacks: str = "",
|
||||||
|
has_lyrics: str = "", tunings: str = "", mastery: str = "",
|
||||||
|
match: str = "", genre: str = "",
|
||||||
|
provider: str = "local"):
|
||||||
|
"""Album-condensed browse: distinct (artist, album) groups with a track count
|
||||||
|
and a representative cover song. Paged by album. Same filters as /api/library."""
|
||||||
|
size = min(size, 500)
|
||||||
|
library_provider = _get_library_provider(provider)
|
||||||
|
_require_library_provider_capability(library_provider, "library.read")
|
||||||
|
albums, total = await _call_library_provider_async(
|
||||||
|
library_provider, "query_albums",
|
||||||
|
page=page, size=size, mastery=_split_csv(mastery),
|
||||||
|
match_states=_split_csv(match), genre=_split_csv(genre),
|
||||||
|
**_library_filter_args(
|
||||||
|
q=q, favorites=favorites, format=format, artist=artist, album=album,
|
||||||
|
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||||
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
|
has_lyrics=has_lyrics, tunings=tunings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"albums": albums, "total": total, "page": page, "size": size}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/artists")
|
||||||
|
async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page: int = 0,
|
||||||
|
size: int = 50, format: str = "",
|
||||||
|
artist: str = "", album: str = "",
|
||||||
|
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||||
|
stems_has: str = "", stems_lacks: str = "",
|
||||||
|
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||||
|
naming_mode: str = "legacy"):
|
||||||
|
"""Get artists grouped by letter with albums and songs (for tree view)."""
|
||||||
|
size = min(size, 100)
|
||||||
|
library_provider = _get_library_provider(provider)
|
||||||
|
_require_library_provider_capability(library_provider, "library.read")
|
||||||
|
artists, total = await _call_library_provider_async(
|
||||||
|
library_provider,
|
||||||
|
"query_artists",
|
||||||
|
letter=letter,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
naming_mode=naming_mode,
|
||||||
|
**_library_filter_args(
|
||||||
|
q=q, favorites=favorites, format=format,
|
||||||
|
artist=artist, album=album,
|
||||||
|
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||||
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
|
has_lyrics=has_lyrics, tunings=tunings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"artists": artists, "total_artists": total, "page": page, "size": size}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/stats")
|
||||||
|
async def library_stats(favorites: int = 0, q: str = "", format: str = "",
|
||||||
|
artist: str = "", album: str = "",
|
||||||
|
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||||
|
stems_has: str = "", stems_lacks: str = "",
|
||||||
|
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||||
|
match: str = "",
|
||||||
|
sort: str = "artist", sort_letters: int = 0,
|
||||||
|
group: int = 0, naming_mode: str = "legacy"):
|
||||||
|
"""Aggregate stats for the UI. Accepts the same filter params as
|
||||||
|
/api/library so the letter bar mirrors the active grid filter set.
|
||||||
|
`sort` selects the column the jump rail's `sort_letters` keys on;
|
||||||
|
`sort_letters=1` opts into that breakdown (the rail), so non-rail
|
||||||
|
callers skip the extra per-letter aggregate. `group=1` counts works not
|
||||||
|
charts (mirrors the grouped grid)."""
|
||||||
|
library_provider = _get_library_provider(provider)
|
||||||
|
_require_library_provider_capability(library_provider, "library.read")
|
||||||
|
return await _call_library_provider_async(
|
||||||
|
library_provider,
|
||||||
|
"query_stats",
|
||||||
|
naming_mode=naming_mode,
|
||||||
|
sort=sort,
|
||||||
|
want_sort_letters=bool(sort_letters),
|
||||||
|
group=bool(group),
|
||||||
|
# The match facet rides the stats call too — the A–Z rail's letter
|
||||||
|
# counts must agree with the grid under the facet or its cumulative
|
||||||
|
# seek + sizer geometry break.
|
||||||
|
match_states=_split_csv(match),
|
||||||
|
**_library_filter_args(
|
||||||
|
q=q, favorites=favorites, format=format,
|
||||||
|
artist=artist, album=album,
|
||||||
|
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||||
|
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||||
|
has_lyrics=has_lyrics, tunings=tunings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/genres")
|
||||||
|
def library_genres(provider: str = "local"):
|
||||||
|
"""Distinct non-empty genres for the filter facet.
|
||||||
|
|
||||||
|
Genres are a local-library facet: they're populated from the feedpak
|
||||||
|
`genres` field at scan time and live in the local meta DB. Local-backed
|
||||||
|
providers (the local library and its smart collections, kind="local")
|
||||||
|
share that DB, so they surface the same set. Remote providers don't
|
||||||
|
expose genres here, so return an empty facet for them — the client then
|
||||||
|
hides the filter rather than offering local genres that don't apply to
|
||||||
|
the remote grid. Mirrors the local/remote gating used elsewhere for
|
||||||
|
provider calls (see `_call_library_provider`)."""
|
||||||
|
library_provider = _get_library_provider(provider)
|
||||||
|
kind = str(appstate.library_providers.provider_field(library_provider, "kind", "") or "")
|
||||||
|
is_remote = kind not in ("", "local") if kind else provider != "local"
|
||||||
|
if is_remote:
|
||||||
|
return {"genres": []}
|
||||||
|
with appstate.meta_db._lock:
|
||||||
|
g = appstate.meta_db._effective_genre_expr()
|
||||||
|
rows = appstate.meta_db.conn.execute(
|
||||||
|
f"SELECT g FROM (SELECT DISTINCT ({g}) AS g FROM songs) "
|
||||||
|
"WHERE g IS NOT NULL AND g != '' ORDER BY g COLLATE NOCASE"
|
||||||
|
).fetchall()
|
||||||
|
return {"genres": [r[0] for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/tuning-names")
|
||||||
|
async def list_tuning_names(provider: str = "local"):
|
||||||
|
"""Distinct tuning names present in the library, with per-tuning
|
||||||
|
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
|
||||||
|
so names appear in the same musical order the sort uses
|
||||||
|
(feedBack#22) — E Standard first, then nearest neighbors."""
|
||||||
|
library_provider = _get_library_provider(provider)
|
||||||
|
_require_library_provider_capability(library_provider, "library.read")
|
||||||
|
return await _call_library_provider_async(library_provider, "tuning_names")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library/practice-suggestions")
|
||||||
|
def api_practice_suggestions(limit: int = 8):
|
||||||
|
"""Growth-edge 'practice next' shelf (P3): attempted-but-not-mastered songs
|
||||||
|
ranked by difficulty-appropriateness × mastery-proximity, joined to song
|
||||||
|
metadata. Replaces the recency-only 'Keep practicing' shelf ordering. Local
|
||||||
|
library only — reads local practice stats."""
|
||||||
|
from urllib.parse import quote
|
||||||
|
out = []
|
||||||
|
for r in appstate.meta_db.growth_edge_suggestions(limit):
|
||||||
|
meta = appstate.meta_db.conn.execute(
|
||||||
|
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||||
|
(r["filename"],),
|
||||||
|
).fetchone()
|
||||||
|
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||||
|
out.append({
|
||||||
|
**r,
|
||||||
|
"title": title or r["filename"],
|
||||||
|
"artist": artist or "",
|
||||||
|
"tuning_name": tuning_name or "",
|
||||||
|
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/collections")
|
||||||
|
def api_list_collections():
|
||||||
|
"""Smart/dynamic collections (saved live library filters)."""
|
||||||
|
return {"collections": appstate.meta_db.list_collections()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/collections")
|
||||||
|
def api_create_collection(data: dict):
|
||||||
|
"""Create a collection from a name + a set of library filter rules. It
|
||||||
|
immediately appears as a source in the library provider picker."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||||
|
name = _clean_str(data.get("name"))
|
||||||
|
if not name:
|
||||||
|
return JSONResponse({"error": "name required"}, status_code=400)
|
||||||
|
col = appstate.meta_db.create_collection(name, _sanitize_collection_rules(data.get("rules")))
|
||||||
|
_sync_collection_provider(col)
|
||||||
|
return {"ok": True, "collection": col}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/collections/{pid}")
|
||||||
|
def api_update_collection(pid: int, data: dict):
|
||||||
|
"""Rename a collection and/or replace its rules."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||||
|
name = _clean_str(data.get("name")) or None
|
||||||
|
rules = _sanitize_collection_rules(data["rules"]) if "rules" in data else None
|
||||||
|
col = appstate.meta_db.update_collection(pid, name=name, rules=rules)
|
||||||
|
if col is None:
|
||||||
|
return JSONResponse({"error": "collection not found"}, status_code=404)
|
||||||
|
_sync_collection_provider(col)
|
||||||
|
return {"ok": True, "collection": col}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/collections/{pid}")
|
||||||
|
def api_delete_collection(pid: int):
|
||||||
|
"""Delete a collection and unregister its provider."""
|
||||||
|
if not appstate.meta_db.is_collection(pid):
|
||||||
|
return JSONResponse({"error": "collection not found"}, status_code=404)
|
||||||
|
appstate.meta_db.delete_playlist(pid)
|
||||||
|
_unregister_collection_provider(pid)
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Media/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}).
|
||||||
|
|
||||||
|
Extracted verbatim from server.py (R3) except @app->@router and the cache/static
|
||||||
|
path seams (AUDIO_CACHE_DIR->appstate.audio_cache_dir, STATIC_DIR->
|
||||||
|
appstate.static_dir, SLOPPAK_CACHE_DIR->appstate.sloppak_cache_dir).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
|
import appstate
|
||||||
|
import sloppak as sloppak_mod
|
||||||
|
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||||
|
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger("feedBack.server")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def _resolve_sloppak_local_file(filename: str, rel_path: str):
|
||||||
|
"""Resolve a file inside a sloppak to its on-disk path.
|
||||||
|
|
||||||
|
Applies the same containment guards as ``serve_sloppak_file``. Returns the
|
||||||
|
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
|
||||||
|
callers can produce their endpoint-appropriate response.
|
||||||
|
"""
|
||||||
|
dlc = _get_dlc_dir()
|
||||||
|
if not dlc:
|
||||||
|
return ("not configured", 404)
|
||||||
|
# `filename` is caller-controlled. Contain it under DLC_DIR before it
|
||||||
|
# reaches the resolver (see serve_sloppak_file for the traversal rationale).
|
||||||
|
resolved = _resolve_dlc_path(dlc, filename)
|
||||||
|
if resolved is None:
|
||||||
|
return ("forbidden", 403)
|
||||||
|
# Confine to actual sloppak bundles — otherwise any plain subdirectory
|
||||||
|
# would become a read-any-file-under-DLC_DIR source.
|
||||||
|
if not sloppak_mod.is_sloppak(resolved):
|
||||||
|
return ("not found", 404)
|
||||||
|
# Canonicalise the cache key against the resolved path so equivalent URL
|
||||||
|
# forms of the same sloppak converge on one _source_cache entry.
|
||||||
|
try:
|
||||||
|
filename = resolved.relative_to(dlc.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
# safe_join already proved containment; fail closed regardless.
|
||||||
|
return ("forbidden", 403)
|
||||||
|
src = sloppak_mod.get_cached_source_dir(filename)
|
||||||
|
if src is None:
|
||||||
|
try:
|
||||||
|
src = sloppak_mod.resolve_source_dir(filename, dlc, appstate.sloppak_cache_dir)
|
||||||
|
except Exception:
|
||||||
|
return ("not found", 404)
|
||||||
|
# Prevent path traversal within the sloppak.
|
||||||
|
target = (src / rel_path).resolve()
|
||||||
|
try:
|
||||||
|
target.relative_to(src.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return ("forbidden", 403)
|
||||||
|
if not target.exists() or not target.is_file():
|
||||||
|
return ("not found", 404)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
|
||||||
|
def serve_sloppak_file(filename: str, rel_path: str):
|
||||||
|
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
|
||||||
|
result = _resolve_sloppak_local_file(filename, rel_path)
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
error, status = result
|
||||||
|
return JSONResponse({"error": error}, status)
|
||||||
|
target = result
|
||||||
|
ext = target.suffix.lower()
|
||||||
|
mt = {
|
||||||
|
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
|
||||||
|
".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac",
|
||||||
|
".m4a": "audio/mp4",
|
||||||
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png", ".webp": "image/webp",
|
||||||
|
".json": "application/json",
|
||||||
|
}.get(ext)
|
||||||
|
return FileResponse(str(target), media_type=mt) if mt else FileResponse(str(target))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/audio-local-path")
|
||||||
|
def audio_local_path(url: str, request: Request):
|
||||||
|
"""Return absolute local filesystem path for a song URL (Electron desktop only).
|
||||||
|
|
||||||
|
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments —
|
||||||
|
no scheme, no host, no query string, no fragment. The resolved path must stay
|
||||||
|
inside appstate.audio_cache_dir or appstate.static_dir; ``..`` traversal, backslashes, and
|
||||||
|
absolute ``filename`` values are rejected.
|
||||||
|
|
||||||
|
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
|
||||||
|
emitted by the highway song payload) and resolves it to the unpacked
|
||||||
|
sloppak cache file via the same containment guards as
|
||||||
|
``serve_sloppak_file`` — this lets the desktop engine play a feedpak
|
||||||
|
full-mix natively under WASAPI-exclusive output.
|
||||||
|
|
||||||
|
This endpoint returns a raw filesystem path and is intended exclusively for
|
||||||
|
the Electron desktop process (which runs on loopback). Requests from non-
|
||||||
|
loopback clients are rejected with 403.
|
||||||
|
"""
|
||||||
|
# Loopback-only — only the local Electron process should call this
|
||||||
|
client_host = request.client.host if request.client else None
|
||||||
|
try:
|
||||||
|
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
|
||||||
|
except ValueError:
|
||||||
|
is_loopback = client_host == "localhost"
|
||||||
|
if not is_loopback:
|
||||||
|
return JSONResponse({"error": "forbidden"}, status_code=403)
|
||||||
|
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
|
||||||
|
# Both segments arrive percent-encoded (built with urllib quote() in the
|
||||||
|
# highway payload); decode before handing to the shared resolver, which
|
||||||
|
# re-applies all containment guards on the decoded values.
|
||||||
|
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
|
||||||
|
if slop_match:
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
result = _resolve_sloppak_local_file(
|
||||||
|
unquote(slop_match.group(1)), unquote(slop_match.group(2))
|
||||||
|
)
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
error, status = result
|
||||||
|
return JSONResponse({"error": error}, status_code=status)
|
||||||
|
return JSONResponse({"path": str(result)})
|
||||||
|
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
|
||||||
|
if not re.fullmatch(r"/audio/[^?#]+", url):
|
||||||
|
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||||
|
filename = url[len("/audio/"):]
|
||||||
|
# Reject traversal, absolute paths, and backslash separators
|
||||||
|
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
|
||||||
|
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||||
|
for d in [appstate.audio_cache_dir, appstate.static_dir]:
|
||||||
|
candidate = (d / filename).resolve()
|
||||||
|
# Ensure resolved path is inside the allowed directory
|
||||||
|
try:
|
||||||
|
candidate.relative_to(d.resolve())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if candidate.is_file():
|
||||||
|
return JSONResponse({"path": str(candidate)})
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audio/{filename:path}")
|
||||||
|
def serve_audio(filename: str):
|
||||||
|
"""Serve audio files from the writable audio cache directory."""
|
||||||
|
# Reject traversal attempts and absolute-path components
|
||||||
|
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
for d in [appstate.audio_cache_dir, appstate.static_dir]:
|
||||||
|
candidate = (d / filename).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(d.resolve())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if candidate.is_file():
|
||||||
|
return FileResponse(str(candidate))
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
@@ -25,6 +25,7 @@ def client_and_server(tmp_path, monkeypatch):
|
|||||||
static_tmp = tmp_path / "static"
|
static_tmp = tmp_path / "static"
|
||||||
static_tmp.mkdir()
|
static_tmp.mkdir()
|
||||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||||
|
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
|
||||||
# Pass client=("127.0.0.1", 50000) so request.client.host is a loopback address
|
# Pass client=("127.0.0.1", 50000) so request.client.host is a loopback address
|
||||||
test_client = TestClient(server.app, client=("127.0.0.1", 50000))
|
test_client = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||||
try:
|
try:
|
||||||
@@ -123,6 +124,7 @@ def dlc_client(tmp_path, monkeypatch):
|
|||||||
static_tmp = tmp_path / "static"
|
static_tmp = tmp_path / "static"
|
||||||
static_tmp.mkdir()
|
static_tmp.mkdir()
|
||||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||||
|
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
|
||||||
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||||
try:
|
try:
|
||||||
yield tc, server, dlc
|
yield tc, server, dlc
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ result, not stored songs.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import library_registry
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -133,7 +134,7 @@ def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
|
|||||||
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
|
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
|
||||||
)
|
)
|
||||||
server_mod.meta_db.conn.commit()
|
server_mod.meta_db.conn.commit()
|
||||||
server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))
|
library_registry._sync_collection_provider(server_mod.meta_db.get_collection(cid))
|
||||||
|
|
||||||
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
|
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
|
||||||
assert r.status_code == 200 # no 500/503 from bad rules
|
assert r.status_code == 200 # no 500/503 from bad rules
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch):
|
|||||||
static_tmp = tmp_path / "static"
|
static_tmp = tmp_path / "static"
|
||||||
static_tmp.mkdir()
|
static_tmp.mkdir()
|
||||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||||
|
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
|
||||||
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||||
try:
|
try:
|
||||||
yield tc, server, dlc
|
yield tc, server, dlc
|
||||||
|
|||||||
Reference in New Issue
Block a user