mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
* feat(audio): route feedpak full-mix natively under exclusive output Song playback runs through the renderer, which WASAPI-exclusive (and ASIO) output silences. Route single-mix feedpaks (stem-less original_audio packs AND single-stem packs) onto the engine's backing transport when the output device type is exclusive-style, and migrate back to HTML5 when it isn't. Extends /api/audio-local-path to resolve /api/sloppak/.../file/... URLs via the same containment guards as serve_sloppak_file. Multi-stem packs stay on the WebAudio path (Phase 2). Includes [feedpak-route] transition-gated diagnostics logging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2) Under exclusive-style output the native backing transport (Phase 1, #824) carries loose /audio/ songs and feedpak full-mixes, but not the stems plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps the renderer-side master with an AudioWorklet, re-points the owning AudioContext at a null sink so it keeps rendering without a device, and pushes ~10 ms chunks over IPC into the desktop engine's renderer bus (feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared mode. Validated by the fix12 tester spike: null-sink rendering works, clocks hold, no overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(diag): --debug ASIO routing diagnostics in static bundle Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug); inert in the Docker sphere and normal desktop runs. - [asio-diag] getCurrentDevice= full device object on outputType change (catches ASIO drivers reporting a non-'ASIO' type name) - [asio-diag] renderer-bus: full feeder decision vector, change-gated (running/exclusive/stems/juceMode/elementSong/want/mode) - [asio-diag] setSink: every sink flip with ctx state + rate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(audio): loopback feeder mode — all app audio under exclusive/ASIO Tester-confirmed (2026-07-11 log): song previews and other plugin-private audio bypass the per-surface feeder taps and leak to the default WASAPI device under ASIO output. Also confirmed: the element capture path poisons itself when highway_3d already owns #audio's one-shot MediaElementSource (InvalidStateError with _elCtx assigned pre-throw → TypeError every later tick). - New preferred mode 'loopback': one getDisplayMedia frame-audio capture (desktop main answers with the app's own frame) covers song, previews, and UI sounds for the whole exclusive session — engages even with no song loaded. Local playback silenced via suppressLocalAudioPlayback, page-mute IPC fallback otherwise. - Sticky fallback to the existing stems/element surface modes when capture is unavailable (old desktop main, denied, Docker sphere). - Element capture: assign module state only after the whole chain succeeds; close the context on failure — collision now retries clean. - Failed engage now disables the bus and tears down loopback (no more bus-enabled-with-no-producer stranding). - Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky fallback, collision retry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(audio): loopback feeder mode — all app audio under exclusive/ASIO Tester-confirmed (2026-07-11 log): song previews and other plugin-private audio bypass the per-surface feeder taps and leak to the default WASAPI device under ASIO output. Also confirmed: the element capture path poisons itself when highway_3d already owns #audio's one-shot MediaElementSource (InvalidStateError with _elCtx assigned pre-throw → TypeError every later tick). - New preferred mode 'loopback': one getDisplayMedia frame-audio capture (desktop main answers with the app's own frame) covers song, previews, and UI sounds for the whole exclusive session — engages even with no song loaded. Local playback silenced via suppressLocalAudioPlayback, page-mute IPC fallback otherwise. - Sticky fallback to the existing stems/element surface modes when capture is unavailable (old desktop main, denied, Docker sphere). - Element capture: assign module state only after the whole chain succeeds; close the context on failure — collision now retries clean. - Failed engage now disables the bus and tears down loopback (no more bus-enabled-with-no-producer stranding). - Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky fallback, collision retry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(audio): close loopback capture context on teardown (release tap worklet) The loopback context was reused across engages (_lbCtx || new), but teardown only stopped the stream + deactivated the tap — never closing the context or detaching the worklet node. Each exclusive<->shared switch orphaned a live tap worklet on the long-lived context. Use a fresh context per session and close it on disengage. Adds a test asserting the context is closed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(diag): install-time + uncaught-error diagnostics for the reroute chain 2026-07-11 tester log showed the routing watcher and renderer-bus feeder never installed (zero [feedpak-route]/[renderer-bus] lines) plus an uncaught SyntaxError with no source location — nothing in the log said why. New: - global error/unhandledrejection tap logging message + filename:line:col (error events carry the location even for parse errors in other scripts) - explicit install / NOT-installed lines for watcher and feeder (incl. loopback capability probe) - DOMException detail (name/message/stack head) in the feeder retry warn — the console-message forward stringified it to [object DOMException] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(static): force conditional revalidation on /static (Cache-Control: no-cache) Without Cache-Control Chromium's heuristic freshness (10% of file age) serves /static/app.js from disk cache for hours-to-days without revalidating. Desktop consequence: a new build's window ran the previous build's app.js — the 2026-07-11 ASIO investigation traced 'routing watcher never installed' + a stems module-plugin SyntaxError to exactly this (stale loader predating scriptType support). no-cache keeps caching but revalidates via ETag — unchanged files still cost only a 304. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(diag): gate install-time + uncaught-error [asio-diag] lines on --debug The error tap and install lines from the previous diag commit were unconditional. Now: error/rejection taps check _asioDiagEnabled() at event time; install lines log deferred once the async debugEnabled() resolves true. The NOT-installed anomaly lines stay bridge-gated (window.feedBackDesktop present) instead — a broken bridge can't deliver the debug flag, they fire at most once, and only in the broken state they exist to witness. Docker sphere: fully silent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2419 lines
96 KiB
Python
2419 lines
96 KiB
Python
"""FeedBack — FastAPI backend serving highway viewer + library."""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import secrets
|
||
import stat
|
||
import sys
|
||
import tempfile
|
||
import shutil
|
||
from pathlib import Path
|
||
from typing import ClassVar
|
||
|
||
from logging_setup import configure_logging
|
||
from env_compat import getenv_compat
|
||
configure_logging()
|
||
|
||
log = logging.getLogger("feedBack.server")
|
||
|
||
from fastapi import FastAPI, File
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||
|
||
from safepath import safe_join
|
||
from appconfig import _load_config
|
||
from tunings import (
|
||
DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS,
|
||
apply_reference_pitch, tuning_name,
|
||
)
|
||
# The library metadata cache. `MetadataDB` and the query helpers it owns live in
|
||
# their own module; the `meta_db` singleton below stays here. The private names
|
||
# are re-imported because callers outside the DB layer still use them.
|
||
from metadata_db import MetadataDB
|
||
# The audio-effect routing index. Same shape as metadata_db: the class lives in
|
||
# its own module, the `audio_effect_mappings` singleton below stays here.
|
||
from audio_effects_db import AudioEffectsMappingDB
|
||
from library_registry import ( # registry classes + collection lifecycle moved to lib (R3)
|
||
LibraryProviderRegistry, LocalLibraryProvider,
|
||
_sync_collection_provider,
|
||
)
|
||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||
# The router seam. Imported as a module (never `from appstate import ...`) so
|
||
# `appstate.configure(...)` below publishes into the same namespace routers read.
|
||
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||
import appstate
|
||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||
from routers import tunings as tunings_router
|
||
import enrichment
|
||
from routers import art as art_router
|
||
from routers import settings as settings_router
|
||
from routers import song as song_router
|
||
from routers import library as library_router
|
||
from routers import enrichment as enrichment_routes
|
||
from routers import media as media_router
|
||
from routers import artist as artist_router
|
||
import sloppak as sloppak_mod
|
||
import loosefolder as loosefolder_mod
|
||
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
||
# tier classification + response parsing. No network/DB in there — the
|
||
# throttled transport and the song_enrichment writes live in this module.
|
||
# Metadata extraction lives in a side-effect-free module so ProcessPool
|
||
# scan workers can import + unpickle _scan_one without re-running this
|
||
# module's import-time side effects (see lib/scan_worker.py).
|
||
from scan_worker import _extract_meta_for_file, _relpath, _scan_one
|
||
|
||
import concurrent.futures
|
||
import inspect
|
||
import multiprocessing
|
||
import re
|
||
import threading
|
||
import time
|
||
import uuid
|
||
import warnings
|
||
import xml.etree.ElementTree as ET
|
||
|
||
from fastapi import Request
|
||
|
||
app = FastAPI(title="FeedBack")
|
||
|
||
# Plugins that maintain session stores can register a cleanup callback here.
|
||
# The demo-mode janitor calls every registered hook once per hour so stale
|
||
# sessions are swept without the core needing to know plugin internals.
|
||
_DEMO_JANITOR_HOOKS: list = []
|
||
_DEMO_JANITOR_HOOKS_LOCK = threading.Lock()
|
||
_DEMO_JANITOR_STARTED = False
|
||
_DEMO_JANITOR_STOP = threading.Event()
|
||
_DEMO_JANITOR_THREAD: threading.Thread | None = None
|
||
|
||
|
||
def register_demo_janitor_hook(fn) -> None:
|
||
"""Register a zero-argument callable to be invoked hourly by the demo
|
||
janitor. Plugins call this from their ``setup(app, context)`` when they
|
||
want to participate in session cleanup under demo mode.
|
||
|
||
The callable must accept no required arguments. Async (coroutine)
|
||
functions are rejected: the janitor runs in a plain thread and cannot
|
||
await coroutines.
|
||
"""
|
||
if not callable(fn):
|
||
raise TypeError(
|
||
f"register_demo_janitor_hook expects a callable, got {type(fn).__name__!r}"
|
||
)
|
||
# Reject coroutine functions — check both the callable itself and its
|
||
# __call__ method so objects with an async __call__ (e.g. class instances,
|
||
# functools.partial wrappers around async functions) are also caught.
|
||
_call = getattr(fn, "__call__", None)
|
||
if inspect.iscoroutinefunction(fn) or (
|
||
_call is not None and inspect.iscoroutinefunction(_call)
|
||
):
|
||
raise TypeError(
|
||
"register_demo_janitor_hook does not accept async functions; "
|
||
"the janitor runs in a plain thread and cannot await coroutines"
|
||
)
|
||
# Validate that the callable accepts zero required arguments so it won't
|
||
# crash at sweep time (hourly, far from the registration site).
|
||
try:
|
||
sig = inspect.signature(fn)
|
||
except ValueError:
|
||
# inspect.signature() raises ValueError for built-in C callables whose
|
||
# signature cannot be determined. Accept them as-is; if they fail at
|
||
# runtime the janitor will catch and log the exception.
|
||
pass
|
||
else:
|
||
required = [
|
||
p for p in sig.parameters.values()
|
||
if p.default is inspect.Parameter.empty
|
||
and p.kind not in (
|
||
inspect.Parameter.VAR_POSITIONAL,
|
||
inspect.Parameter.VAR_KEYWORD,
|
||
)
|
||
]
|
||
if required:
|
||
raise TypeError(
|
||
f"register_demo_janitor_hook expects a zero-argument callable; "
|
||
f"{fn!r} has {len(required)} required parameter(s): "
|
||
+ ", ".join(p.name for p in required)
|
||
)
|
||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||
_DEMO_JANITOR_HOOKS.append(fn)
|
||
|
||
|
||
def _run_janitor_hook(hook) -> None:
|
||
"""Run a single janitor hook inline, swallowing and logging any exception.
|
||
|
||
If the hook returns an awaitable (e.g. a coroutine slipped through the
|
||
async-function guard), the coroutine is closed immediately to avoid
|
||
``RuntimeWarning: coroutine was never awaited`` noise, and a warning is
|
||
emitted so the plugin author knows to fix their hook.
|
||
"""
|
||
try:
|
||
result = hook()
|
||
except Exception:
|
||
log.exception("janitor hook %r raised", hook)
|
||
return
|
||
if inspect.iscoroutine(result):
|
||
# A coroutine slipped through the async-function guard (e.g. via a
|
||
# wrapper/partial). Close it to suppress "coroutine never awaited",
|
||
# then warn so the plugin author knows to fix their hook.
|
||
try:
|
||
result.close()
|
||
except Exception:
|
||
log.exception("error closing coroutine from janitor hook %r", hook)
|
||
warnings.warn(
|
||
f"janitor hook {hook!r} returned a coroutine; "
|
||
"hooks must be plain synchronous callables — "
|
||
"register_demo_janitor_hook does not accept async functions",
|
||
RuntimeWarning,
|
||
stacklevel=1,
|
||
)
|
||
elif inspect.isawaitable(result):
|
||
# Future/Task: no .close() method; just warn and leave it alone.
|
||
warnings.warn(
|
||
f"janitor hook {hook!r} returned an awaitable (Future/Task); "
|
||
"hooks must be plain synchronous callables",
|
||
RuntimeWarning,
|
||
stacklevel=1,
|
||
)
|
||
|
||
|
||
_DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||
("POST", re.compile(r"^/api/settings$")),
|
||
("POST", re.compile(r"^/api/settings/import$")),
|
||
("POST", re.compile(r"^/api/settings/reset$")),
|
||
("POST", re.compile(r"^/api/rescan$")),
|
||
("POST", re.compile(r"^/api/rescan/full$")),
|
||
("POST", re.compile(r"^/api/songs/upload$")),
|
||
("DELETE", re.compile(r"^/api/song/.+$")),
|
||
("POST", re.compile(r"^/api/favorites/toggle$")),
|
||
("POST", re.compile(r"^/api/loops$")),
|
||
("DELETE", re.compile(r"^/api/loops/[^/]+$")),
|
||
("POST", re.compile(r"^/api/audio-effects/mappings$")),
|
||
("DELETE", re.compile(r"^/api/audio-effects/mappings/[^/]+$")),
|
||
("POST", re.compile(r"^/api/audio-effects/mappings/[^/]+/activate$")),
|
||
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
|
||
("POST", re.compile(r"^/api/song/.*/meta$")),
|
||
("POST", re.compile(r"^/api/song/.*/art/upload$")),
|
||
("PUT", re.compile(r"^/api/song/.+/overrides$")),
|
||
("GET", re.compile(r"^/api/plugins/updates$")),
|
||
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
|
||
("POST", re.compile(r"^/api/plugins/editor/save$")),
|
||
("POST", re.compile(r"^/api/plugins/editor/build$")),
|
||
("POST", re.compile(r"^/api/plugins/editor/upload-art$")),
|
||
("POST", re.compile(r"^/api/plugins/editor/upload-audio$")),
|
||
("POST", re.compile(r"^/api/plugins/editor/youtube-audio$")),
|
||
("POST", re.compile(r"^/api/plugins/editor/import-gp$")),
|
||
("POST", re.compile(r"^/api/plugins/editor/import-midi$")),
|
||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/align$")),
|
||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/generate-pitch$")),
|
||
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/save-lyrics$")),
|
||
("POST", re.compile(r"^/api/plugins/lyrics_sync/align$")),
|
||
("POST", re.compile(r"^/api/plugins/lyrics_sync/save$")),
|
||
("POST", re.compile(r"^/api/plugins/studio/sessions/[^/]+/extract-drums$")),
|
||
("POST", re.compile(r"^/api/diagnostics/export$")),
|
||
("GET", re.compile(r"^/api/diagnostics/preview$")),
|
||
("GET", re.compile(r"^/api/diagnostics/hardware$")),
|
||
# Bundled core plugin — video background upload/delete
|
||
("POST", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||
("DELETE", re.compile(r"^/api/plugins/highway_3d/files$")),
|
||
# fee[dB]ack v0.3.0 write endpoints — demo mode is read-only, so block the
|
||
# new profile / XP / stats / playlists / saved mutators too.
|
||
("POST", re.compile(r"^/api/profile$")),
|
||
("POST", re.compile(r"^/api/profile/avatar$")),
|
||
("POST", re.compile(r"^/api/xp/award$")),
|
||
("POST", re.compile(r"^/api/stats$")),
|
||
("POST", re.compile(r"^/api/playlists$")),
|
||
("PATCH", re.compile(r"^/api/playlists/[^/]+$")),
|
||
("DELETE", re.compile(r"^/api/playlists/[^/]+$")),
|
||
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
|
||
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
|
||
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
|
||
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||
("POST", re.compile(r"^/api/saved/toggle$")),
|
||
# Progression (spec 010) write endpoints — demo mode stays read-only.
|
||
("POST", re.compile(r"^/api/progression/paths$")),
|
||
("POST", re.compile(r"^/api/progression/onboarding$")),
|
||
("POST", re.compile(r"^/api/progression/events$")),
|
||
("POST", re.compile(r"^/api/shop/buy$")),
|
||
("POST", re.compile(r"^/api/shop/equip$")),
|
||
# Enrichment (P8): review writes mutate the local match cache, and the
|
||
# search proxy / manual kick relay to MusicBrainz — none of it belongs to
|
||
# anonymous demo visitors (they'd spend the shared rate limit).
|
||
("POST", re.compile(r"^/api/enrichment/review/.+$")),
|
||
("POST", re.compile(r"^/api/enrichment/kick$")),
|
||
("POST", re.compile(r"^/api/enrichment/cancel$")),
|
||
("POST", re.compile(r"^/api/enrichment/rematch$")),
|
||
("GET", re.compile(r"^/api/enrichment/search$")),
|
||
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
|
||
# and spend the shared AcoustID rate budget on the caller's behalf — same
|
||
# rule as the search/kick relays above; not for anonymous demo visitors.
|
||
("POST", re.compile(r"^/api/enrichment/identify$")),
|
||
("POST", re.compile(r"^/api/enrichment/identify/.+$")),
|
||
# Context menus (R2): the per-song re-match mutates the cache + spends
|
||
# rate limit; Get-info exposes filesystem paths.
|
||
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
|
||
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
|
||
# Gap-fill (R4a) rewrites pack files on disk — never for demo visitors.
|
||
("POST", re.compile(r"^/api/song/.+/gap-fill$")),
|
||
# Art layer (R3): all three mutate server state / touch the network on a
|
||
# visitor's behalf — the base64 upload writes files, the URL fetch makes the
|
||
# server request arbitrary images, and the override delete removes files.
|
||
("POST", re.compile(r"^/api/song/.+/art/upload$")),
|
||
("POST", re.compile(r"^/api/song/.+/art/url$")),
|
||
("DELETE", re.compile(r"^/api/art/.+/override$")),
|
||
# Cover picker (PR-C): read-only, but a cache-miss open spends 1-3
|
||
# throttled Cover Art Archive calls — anonymous demo visitors don't get
|
||
# to spend the shared rate budget (same rule as enrichment search/kick).
|
||
("GET", re.compile(r"^/api/song/.+/art/candidates$")),
|
||
# Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a
|
||
# visitor's behalf AND writes the artist_enrichment cache; refresh
|
||
# re-spends the shared rate limit. The /page route stays open (all-local
|
||
# read). Same rationale as /api/enrichment/search above.
|
||
("GET", re.compile(r"^/api/artist/.+/links$")),
|
||
("POST", re.compile(r"^/api/artist/.+/links/refresh$")),
|
||
]
|
||
|
||
|
||
@app.middleware("http")
|
||
async def _demo_mode_guard(request: Request, call_next):
|
||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1":
|
||
path = request.url.path
|
||
for method, pattern in _DEMO_BLOCKED:
|
||
if request.method == method and pattern.match(path):
|
||
return JSONResponse({"error": "demo mode: read-only"}, status_code=403)
|
||
response = await call_next(request)
|
||
if request.method == "GET" and path == "/" and "feedBack_demo_session" not in request.cookies:
|
||
forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip()
|
||
is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
||
response.set_cookie(
|
||
"feedBack_demo_session", str(uuid.uuid4()),
|
||
max_age=86400, httponly=True, samesite="lax",
|
||
secure=is_secure,
|
||
)
|
||
return response
|
||
return await call_next(request)
|
||
|
||
from asgi_correlation_id import CorrelationIdMiddleware
|
||
|
||
# validator=None accepts any non-empty inbound X-Request-ID value, including
|
||
# opaque proxy-generated hex strings, not just RFC-4122 UUIDs.
|
||
app.add_middleware(CorrelationIdMiddleware, validator=None)
|
||
|
||
STATIC_DIR = Path(__file__).parent / "static"
|
||
try:
|
||
STATIC_DIR.mkdir(exist_ok=True)
|
||
except OSError:
|
||
pass # Read-only in packaged installs
|
||
|
||
# Distinguish "env not set / empty" from "explicitly set". Path("") collapses
|
||
# to Path(".") so we can't recover that signal after the cast — capture the
|
||
# raw env-var string up front and let _get_dlc_dir() consult both. This way
|
||
# `DLC_DIR=.` remains a valid opt-in for cwd while `DLC_DIR=""` (or unset)
|
||
# falls through to the config.json fallback.
|
||
_DLC_DIR_ENV = os.environ.get("DLC_DIR", "").strip()
|
||
DLC_DIR = Path(_DLC_DIR_ENV) if _DLC_DIR_ENV else Path("")
|
||
CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", str(Path.home() / ".local" / "share" / "feedback")))
|
||
|
||
# Writable cache directories (use CONFIG_DIR, not STATIC_DIR which may be read-only)
|
||
ART_CACHE_DIR = CONFIG_DIR / "art_cache"
|
||
AUDIO_CACHE_DIR = CONFIG_DIR / "audio_cache"
|
||
SLOPPAK_CACHE_DIR = CONFIG_DIR / "sloppak_cache"
|
||
|
||
|
||
def _env_flag(name: str) -> bool:
|
||
"""Parse a conventional boolean env flag (honours legacy SLOPSMITH_* alias)."""
|
||
return (getenv_compat(name, "") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||
|
||
|
||
|
||
|
||
meta_db = MetadataDB(CONFIG_DIR)
|
||
audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)
|
||
|
||
# Publish the singletons to the router seam. server.py stays their owner — a
|
||
# `sys.modules.pop("server")` + re-import must keep rebuilding them under a
|
||
# patched CONFIG_DIR — and `routers/` read them back as `appstate.<name>` at
|
||
# call time. See appstate.py for why the reads must be late-bound.
|
||
appstate.configure(
|
||
meta_db=meta_db,
|
||
audio_effect_mappings=audio_effect_mappings,
|
||
config_dir=CONFIG_DIR,
|
||
dlc_dir=DLC_DIR,
|
||
dlc_dir_env=_DLC_DIR_ENV,
|
||
static_dir=STATIC_DIR,
|
||
sloppak_cache_dir=SLOPPAK_CACHE_DIR,
|
||
audio_cache_dir=AUDIO_CACHE_DIR,
|
||
)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
library_providers = LibraryProviderRegistry()
|
||
_local_library_provider = LocalLibraryProvider(meta_db)
|
||
library_providers.register(_local_library_provider)
|
||
# Publish the registry + local provider to the seam for routers/library.py. The
|
||
# registry stays server-owned (plugins register through plugin_context, and the
|
||
# pop-and-reimport fixtures rebuild it under a fresh meta_db).
|
||
appstate.configure(
|
||
library_providers=library_providers,
|
||
local_library_provider=_local_library_provider,
|
||
)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Library + collections routes → routers/library.py (R3) ──────────────────
|
||
app.include_router(library_router.router)
|
||
|
||
|
||
|
||
|
||
# Boot scan: surface every saved collection as a source.
|
||
for _c in meta_db.list_collections():
|
||
_sync_collection_provider(_c)
|
||
|
||
|
||
def register_library_provider(provider: object, *, replace: bool = False, owner_plugin_id: str | None = None) -> object:
|
||
return library_providers.register(provider, replace=replace, owner_plugin_id=owner_plugin_id)
|
||
|
||
|
||
def unregister_library_provider(provider_id: str) -> bool:
|
||
return library_providers.unregister(provider_id)
|
||
|
||
|
||
class TuningProviderRegistry:
|
||
"""Registry for plugins that contribute custom tunings to the core tuning.read capability."""
|
||
|
||
_ID_RE: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||
|
||
def __init__(self) -> None:
|
||
self._providers: dict[str, callable] = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def register(self, provider_id: str, get_tunings: callable) -> None:
|
||
if not self._ID_RE.match(provider_id):
|
||
raise ValueError(f"tuning provider id {provider_id!r} contains invalid characters")
|
||
if not callable(get_tunings):
|
||
raise TypeError("get_tunings must be callable")
|
||
with self._lock:
|
||
self._providers[provider_id] = get_tunings
|
||
|
||
def unregister(self, provider_id: str) -> None:
|
||
with self._lock:
|
||
self._providers.pop(provider_id, None)
|
||
|
||
def get_merged(self, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> dict:
|
||
"""DEFAULT_TUNINGS scaled to reference_pitch, merged with all provider contributions."""
|
||
result: dict[str, dict[str, list[float]]] = apply_reference_pitch(DEFAULT_TUNINGS, reference_pitch)
|
||
scale = reference_pitch / DEFAULT_REFERENCE_PITCH
|
||
with self._lock:
|
||
providers = list(self._providers.items())
|
||
for provider_id, get_tunings in providers:
|
||
try:
|
||
extra = get_tunings() or {}
|
||
for instrument, names in extra.items():
|
||
if instrument not in result:
|
||
result[instrument] = {}
|
||
for name, freqs in names.items():
|
||
result[instrument][name] = [round(f * scale, 4) for f in freqs]
|
||
except Exception:
|
||
logger.exception("tuning provider %r raised during get_merged()", provider_id)
|
||
return result
|
||
|
||
|
||
tuning_providers = TuningProviderRegistry()
|
||
|
||
|
||
def register_tuning_provider(provider_id: str, get_tunings: callable) -> None:
|
||
tuning_providers.register(provider_id, get_tunings)
|
||
|
||
|
||
def unregister_tuning_provider(provider_id: str) -> None:
|
||
tuning_providers.unregister(provider_id)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Background metadata scan ──────────────────────────────────────────────────
|
||
|
||
|
||
|
||
|
||
|
||
def _stat_for_cache(f: Path) -> tuple[float, int]:
|
||
"""Return (mtime, size) for cache freshness checks.
|
||
|
||
For loose-folder directories the directory's own mtime does not
|
||
change when inner files (audio.wem / *.xml / manifest.json) are
|
||
edited in place, so we aggregate over the contents. archives and
|
||
sloppak files (zip form) use their own stat directly. Sloppak
|
||
*directories* are aggregated too: the editor and the library Edit
|
||
button rewrite their `manifest.yaml` / `arrangements/*.json` in
|
||
place, which does NOT bump the directory's own mtime/size — so
|
||
keying the cache on the bare directory stat would make metadata
|
||
edits invisible to a rescan.
|
||
"""
|
||
# Aggregate inner stats for loose folders. We detect "loose-shape"
|
||
# purely by file presence (xml + wem + optional manifest.json) so
|
||
# this stays O(stat) on the hot path — `/api/song/{filename}` and
|
||
# the background scan call this on every check, and we avoid
|
||
# calling `is_loose_song` here because that would parse XML on
|
||
# every cache lookup.
|
||
if f.is_dir():
|
||
# Skip symlinks pointing outside the song folder — without this
|
||
# an attacker-crafted custom song could keep a stale cache hot by
|
||
# bumping the mtime of an unrelated file via a symlink.
|
||
root = f.resolve()
|
||
def _in_folder(p: Path) -> bool:
|
||
try:
|
||
p.resolve().relative_to(root)
|
||
except (OSError, ValueError):
|
||
return False
|
||
return True
|
||
xmls = [p for p in f.glob("*.xml") if _in_folder(p)]
|
||
wems = [p for p in f.glob("*.wem") if _in_folder(p)]
|
||
inner: list[Path] = []
|
||
if xmls and wems:
|
||
inner = xmls + wems + [p for p in f.glob("manifest.json") if _in_folder(p)]
|
||
else:
|
||
# Sloppak directory: aggregate over the files that an in-place
|
||
# metadata/arrangement edit actually touches. Stems (ogg) are
|
||
# deliberately excluded — they don't change on a metadata edit and
|
||
# stat-ing them on every cache lookup would be wasteful; a stem
|
||
# add/remove rewrites manifest.yaml, which IS covered here.
|
||
man = [
|
||
p for p in (f / "manifest.yaml", f / "manifest.yml")
|
||
if p.exists() and _in_folder(p)
|
||
]
|
||
if man:
|
||
inner = man
|
||
inner += [p for p in f.glob("arrangements/*.json") if _in_folder(p)]
|
||
inner += [p for p in f.glob("drum_tab.json") if _in_folder(p)]
|
||
if inner:
|
||
# Tolerate files vanishing between glob() and stat() —
|
||
# otherwise a concurrent edit/move in DLC_DIR can let an
|
||
# OSError bubble out of _background_scan(), killing the
|
||
# scan thread while `_scan_status["running"]` stays true.
|
||
stats = []
|
||
for p in inner:
|
||
try:
|
||
stats.append(p.stat())
|
||
except OSError:
|
||
continue
|
||
if stats:
|
||
return max(s.st_mtime for s in stats), sum(s.st_size for s in stats)
|
||
st = f.stat()
|
||
return st.st_mtime, st.st_size
|
||
|
||
|
||
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
|
||
_scan_status = dict(_SCAN_STATUS_INIT)
|
||
|
||
_STARTUP_STATUS_INIT = {
|
||
"running": True,
|
||
"phase": "booting",
|
||
"message": "Starting FeedBack server...",
|
||
"current_plugin": "",
|
||
"loaded": 0,
|
||
"total": 0,
|
||
"error": None,
|
||
}
|
||
_startup_status = dict(_STARTUP_STATUS_INIT)
|
||
_startup_status_lock = threading.Lock()
|
||
|
||
_startup_sse_subscribers: set[asyncio.Queue] = set()
|
||
# threading.Lock (not asyncio.Lock) — also acquired from background threads
|
||
# in _notify_startup_sse; held only for set mutations (microseconds).
|
||
_startup_sse_lock = threading.Lock()
|
||
_event_loop: asyncio.AbstractEventLoop | None = None
|
||
|
||
_SSE_POLL_INTERVAL = 2.0 # seconds: idle wait between disconnect checks
|
||
_SSE_KA_INTERVAL = 15.0 # seconds: interval between SSE keepalive data events
|
||
|
||
|
||
def _set_startup_status(**updates):
|
||
global _startup_status
|
||
with _startup_status_lock:
|
||
next_status = dict(_startup_status)
|
||
next_status.update(updates)
|
||
_startup_status = next_status
|
||
snapshot = dict(next_status)
|
||
_notify_startup_sse(snapshot)
|
||
|
||
|
||
def _put_latest(q: asyncio.Queue, snapshot: dict) -> None:
|
||
"""Coalescing put: drain any stale snapshot then put the newest one.
|
||
|
||
Because the queue is bounded to maxsize=1 and this function runs on the
|
||
event loop, consecutive rapid updates replace the queued snapshot with
|
||
the latest state rather than growing an unbounded backlog.
|
||
"""
|
||
while not q.empty():
|
||
try:
|
||
q.get_nowait()
|
||
except asyncio.QueueEmpty:
|
||
break
|
||
try:
|
||
q.put_nowait(snapshot)
|
||
except asyncio.QueueFull:
|
||
pass # shouldn't happen after draining, but be defensive
|
||
|
||
|
||
def _notify_startup_sse(snapshot: dict) -> None:
|
||
loop = _event_loop
|
||
if loop is None or loop.is_closed():
|
||
return
|
||
with _startup_sse_lock:
|
||
for q in _startup_sse_subscribers:
|
||
try:
|
||
loop.call_soon_threadsafe(_put_latest, q, snapshot)
|
||
except RuntimeError:
|
||
# Loop is closing (shutdown race); all remaining subscribers are
|
||
# on the same loop and equally unreachable — break is correct.
|
||
break
|
||
|
||
|
||
def _get_startup_status():
|
||
with _startup_status_lock:
|
||
return dict(_startup_status)
|
||
|
||
|
||
def _make_scan_executor():
|
||
"""Build the executor for the background metadata scan.
|
||
|
||
A `spawn` ProcessPoolExecutor in production. `spawn` (not the platform
|
||
default) is mandatory: _background_scan runs on a non-main daemon
|
||
thread, and forking a multithreaded process from a non-main thread can
|
||
deadlock on locks held by other threads at fork time (the default on
|
||
Linux). `spawn` boots a clean interpreter that imports only scan_worker
|
||
(+ its pure lib deps) to unpickle the worker — never this module — so
|
||
workers don't re-run server.py's import-time side effects (reopening
|
||
SQLite, attaching a second RotatingFileHandler, re-registering routes).
|
||
|
||
Tests monkeypatch this to a ThreadPoolExecutor so the scan runs
|
||
in-process and metadata extraction can be mocked.
|
||
"""
|
||
mp_ctx = multiprocessing.get_context("spawn")
|
||
# Default to one worker per core so CPU-bound metadata parsing uses the
|
||
# whole machine (the point of moving to processes).
|
||
# FEEDBACK_MAX_SCAN_WORKERS (set by the Desktop launcher to cap memory
|
||
# usage on low-RAM machines — e.g. 8 GB M2 MacBook Air) takes priority;
|
||
# SCAN_MAX_WORKERS is a legacy override for Docker/bare installs.
|
||
# A malformed override falls back to the core count rather than crashing.
|
||
try:
|
||
max_workers = int(
|
||
getenv_compat("FEEDBACK_MAX_SCAN_WORKERS")
|
||
or os.environ.get("SCAN_MAX_WORKERS")
|
||
or (os.cpu_count() or 1)
|
||
)
|
||
except ValueError:
|
||
max_workers = os.cpu_count() or 1
|
||
# ProcessPoolExecutor raises ValueError on Windows when max_workers > 61
|
||
# (the WaitForMultipleObjects handle limit), so clamp there — otherwise
|
||
# a high-core Windows host can't construct the pool and the scan never
|
||
# starts.
|
||
if sys.platform == "win32":
|
||
max_workers = min(max_workers, 61)
|
||
return concurrent.futures.ProcessPoolExecutor(
|
||
max_workers=max(1, max_workers), mp_context=mp_ctx,
|
||
)
|
||
|
||
|
||
_BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
|
||
_BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
|
||
(
|
||
"feedBack-diagnostic-basic-guitar.sloppak",
|
||
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
|
||
),
|
||
]
|
||
|
||
|
||
def _feedBack_server_root() -> Path:
|
||
"""Directory containing server.py (repo root in dev; resources/feedBack when bundled)."""
|
||
return Path(__file__).resolve().parent
|
||
|
||
|
||
def _builtin_diagnostic_filename() -> str:
|
||
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
|
||
the onboarding challenge target (spec 010)."""
|
||
return f"{_BUILTIN_DIAGNOSTIC_SUBDIR}/{_BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
|
||
|
||
|
||
# Progression content (spec 010): bundled JSON under data/progression/ (paths,
|
||
# quest pools, shop catalog). Loaded lazily-once; invalid entries are logged
|
||
# warnings, never fatal. FEEDBACK_PROGRESSION_DATA overrides the root (tests).
|
||
_progression_content: dict | None = None
|
||
_progression_content_lock = threading.Lock()
|
||
|
||
|
||
def _get_progression_content() -> dict:
|
||
global _progression_content
|
||
if _progression_content is None:
|
||
with _progression_content_lock:
|
||
if _progression_content is None:
|
||
import progression as progression_mod
|
||
root = getenv_compat("FEEDBACK_PROGRESSION_DATA") or (
|
||
_feedBack_server_root() / "data" / "progression"
|
||
)
|
||
content, warnings = progression_mod.load_content(root)
|
||
for warning in warnings:
|
||
log.warning("progression content: %s", warning)
|
||
_progression_content = content
|
||
return _progression_content
|
||
|
||
|
||
# Publish the progression-content accessor into the seam now that it's defined
|
||
# (the main configure() at import-top runs before this def). The cache global +
|
||
# lock stay in server.py, so the `setattr(server, "_progression_content")` test
|
||
# path is unchanged; routers call `appstate.get_progression_content()`.
|
||
appstate.configure(
|
||
get_progression_content=_get_progression_content,
|
||
builtin_diagnostic_filename=_builtin_diagnostic_filename,
|
||
tuning_providers=tuning_providers,
|
||
)
|
||
|
||
|
||
def _copy_builtin_packs(
|
||
root: Path,
|
||
dest_dir: Path,
|
||
sources: list[tuple[str, str]],
|
||
label: str,
|
||
update_existing: bool = True,
|
||
) -> int:
|
||
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
|
||
|
||
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
|
||
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
|
||
bundled). A pack is copied when its destination is missing. Never deletes
|
||
user files; refuses to follow a symlinked seed directory or destination and
|
||
refuses to clobber a non-regular destination (any would let a copy escape
|
||
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
|
||
prefixes every log line.
|
||
|
||
``update_existing`` controls what happens when a *regular* destination file
|
||
already exists: when True (diagnostic seed) a bundle copy newer than the
|
||
destination refreshes it; when False (one-time starter content) an existing
|
||
file is always left as-is so the user's copy is never overwritten.
|
||
|
||
Returns the number of ``sources`` that are present at their destination
|
||
afterwards (freshly seeded, refreshed, or already current) — so callers can
|
||
tell whether every pack made it. A skip (missing source, symlink/non-regular
|
||
refusal, copy error) does not count.
|
||
"""
|
||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
|
||
# and copies would land at the link target, outside the DLC tree. The
|
||
# per-file symlink guard below cannot catch this.
|
||
if dest_dir.is_symlink():
|
||
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
|
||
return 0
|
||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
|
||
# dest_dir *after* the check above cannot redirect the per-file stat /
|
||
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
|
||
# os.replace accepts dir_fd on POSIX even though it isn't listed in
|
||
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
|
||
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
|
||
dir_fd = None
|
||
if (
|
||
hasattr(os, "O_NOFOLLOW")
|
||
and hasattr(os, "O_DIRECTORY")
|
||
and os.open in os.supports_dir_fd
|
||
and os.rename in os.supports_dir_fd
|
||
):
|
||
try:
|
||
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
|
||
except OSError as exc:
|
||
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
|
||
return 0
|
||
|
||
try:
|
||
present = 0
|
||
for dest_name, rel_source in sources:
|
||
source = root / rel_source
|
||
if not source.is_file():
|
||
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
|
||
continue
|
||
|
||
# lstat the destination without following symlinks. Pinned by dir_fd
|
||
# this resolves within the real seed dir, immune to a parent swap.
|
||
try:
|
||
if dir_fd is not None:
|
||
dstat = os.lstat(dest_name, dir_fd=dir_fd)
|
||
else:
|
||
dstat = os.lstat(dest_dir / dest_name)
|
||
dest_exists = True
|
||
dest_islink = stat.S_ISLNK(dstat.st_mode)
|
||
except FileNotFoundError:
|
||
dest_exists = False
|
||
dest_islink = False
|
||
except OSError as exc:
|
||
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
|
||
continue
|
||
|
||
# Refuse to seed through a symlink at the destination name.
|
||
if dest_islink:
|
||
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
|
||
continue
|
||
|
||
# A non-regular destination (directory, fifo, …) the user placed
|
||
# there: never clobber it, and never count it as present — otherwise
|
||
# a one-time seed would mark itself done without a real pack on disk.
|
||
if dest_exists and not stat.S_ISREG(dstat.st_mode):
|
||
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
|
||
continue
|
||
|
||
if dest_exists:
|
||
# A regular file is already there. One-time seeds (starter
|
||
# content) must never overwrite the user's copy; refreshing
|
||
# seeds (diagnostics) replace it only when the bundle is newer.
|
||
if not update_existing:
|
||
log.info("%s: already present %s", label, dest_name)
|
||
present += 1
|
||
continue
|
||
try:
|
||
src_mtime = source.stat().st_mtime
|
||
except OSError as exc:
|
||
log.warning("%s: cannot stat source %s: %s", label, source, exc)
|
||
continue
|
||
if src_mtime <= dstat.st_mtime:
|
||
log.info("%s: already present %s", label, dest_name)
|
||
present += 1
|
||
continue
|
||
action = "updated"
|
||
else:
|
||
action = "seeded"
|
||
|
||
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
|
||
present += 1
|
||
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
|
||
else:
|
||
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
|
||
|
||
return present
|
||
finally:
|
||
if dir_fd is not None:
|
||
os.close(dir_fd)
|
||
|
||
|
||
def _write_builtin_pack(
|
||
source: Path,
|
||
dest_dir: Path,
|
||
dest_name: str,
|
||
dir_fd: int | None,
|
||
) -> bool:
|
||
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
|
||
|
||
Writes to a temp file then ``os.replace()``s onto the final name so a
|
||
symlink raced in at the destination is overwritten (rename semantics), not
|
||
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
|
||
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
|
||
replace), closing the parent-directory TOCTOU; otherwise falls back to
|
||
path-based temp+replace. Returns True on success. Never raises.
|
||
"""
|
||
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
|
||
# can't permanently block later seeds via an EEXIST collision.
|
||
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
||
try:
|
||
src_stat = source.stat()
|
||
except OSError as exc:
|
||
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
|
||
return False
|
||
if dir_fd is not None:
|
||
tmp_fd = None
|
||
try:
|
||
tmp_fd = os.open(
|
||
tmp_name,
|
||
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
|
||
0o644,
|
||
dir_fd=dir_fd,
|
||
)
|
||
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
|
||
tmp_fd = None # fdopen now owns the descriptor
|
||
shutil.copyfileobj(sf, tf)
|
||
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
||
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
|
||
# refresh check matches the shutil.copy2 fallback path. Best-effort.
|
||
try:
|
||
os.utime(
|
||
dest_name,
|
||
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
|
||
dir_fd=dir_fd,
|
||
follow_symlinks=False,
|
||
)
|
||
except OSError as exc:
|
||
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
|
||
return True
|
||
except OSError as exc:
|
||
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
|
||
if tmp_fd is not None:
|
||
try:
|
||
os.close(tmp_fd)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
os.unlink(tmp_name, dir_fd=dir_fd)
|
||
except OSError:
|
||
pass
|
||
return False
|
||
|
||
tmp = None
|
||
try:
|
||
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
|
||
os.close(fd)
|
||
shutil.copy2(source, tmp)
|
||
os.replace(tmp, dest_dir / dest_name)
|
||
tmp = None
|
||
return True
|
||
except OSError as exc:
|
||
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
|
||
return False
|
||
finally:
|
||
if tmp is not None:
|
||
try:
|
||
os.unlink(tmp)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
|
||
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
||
|
||
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
||
when the destination is missing or older than the repo/bundle source.
|
||
Never deletes user files or touches manually copied paths (e.g.
|
||
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
|
||
diagnostic target is always available. Logs and continues on errors.
|
||
"""
|
||
try:
|
||
if dlc is None:
|
||
dlc = _get_dlc_dir()
|
||
if dlc is None:
|
||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||
return
|
||
_copy_builtin_packs(
|
||
_feedBack_server_root(),
|
||
dlc / _BUILTIN_DIAGNOSTIC_SUBDIR,
|
||
_BUILTIN_DIAGNOSTIC_SOURCES,
|
||
"Builtin diagnostic seed",
|
||
)
|
||
except Exception:
|
||
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
|
||
|
||
|
||
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
|
||
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
|
||
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
|
||
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
|
||
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
|
||
# seeded packs surface as ordinary library songs.
|
||
_BUILTIN_STARTER_SUBDIR = "starter"
|
||
_BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
|
||
(
|
||
"beethoven-fur_elise.feedpak",
|
||
"content/starter/beethoven-fur_elise.feedpak",
|
||
),
|
||
(
|
||
"star_spangled_banner.feedpak",
|
||
"content/starter/star_spangled_banner.feedpak",
|
||
),
|
||
(
|
||
"the_adicts-ode-to-joy_vst_cover.feedpak",
|
||
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
|
||
),
|
||
]
|
||
_STARTER_SEED_MARKER = ".starter-content-seeded"
|
||
|
||
|
||
def _seed_builtin_starter_content(dlc: Path | None = None) -> None:
|
||
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
|
||
|
||
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
|
||
folder configured seeds the packs and writes the marker; subsequent runs are
|
||
no-ops, so a user who deletes the starter song does not get it back on the
|
||
next launch. Symlink-safe; never deletes user files. Logs, never raises.
|
||
"""
|
||
try:
|
||
marker = CONFIG_DIR / _STARTER_SEED_MARKER
|
||
# Already seeded? The marker is a sentinel: any existing path there
|
||
# (regular file, or a symlink/dir a user deliberately planted to opt
|
||
# out) means "done" — lstat so we detect it without following a symlink.
|
||
# Worst case of a planted marker is simply no starter content, never a
|
||
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
|
||
# *through* a symlink regardless.
|
||
try:
|
||
os.lstat(marker)
|
||
return
|
||
except FileNotFoundError:
|
||
pass
|
||
except OSError as exc:
|
||
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
|
||
return
|
||
if dlc is None:
|
||
dlc = _get_dlc_dir()
|
||
if dlc is None:
|
||
# No DLC yet — leave the marker unwritten so we retry once a
|
||
# library folder is configured.
|
||
log.debug("Starter content seed: no DLC folder configured, skipping")
|
||
return
|
||
present = _copy_builtin_packs(
|
||
_feedBack_server_root(),
|
||
dlc / _BUILTIN_STARTER_SUBDIR,
|
||
_BUILTIN_STARTER_SOURCES,
|
||
"Starter content seed",
|
||
update_existing=False,
|
||
)
|
||
# Only mark seeding complete once every starter pack is actually in
|
||
# place. If a source was missing or a copy failed, leave the marker
|
||
# unwritten so the next launch retries rather than permanently skipping.
|
||
if present < len(_BUILTIN_STARTER_SOURCES):
|
||
log.info(
|
||
"Starter content seed: %d/%d packs present, will retry next launch",
|
||
present,
|
||
len(_BUILTIN_STARTER_SOURCES),
|
||
)
|
||
return
|
||
# Record completion with an exclusive, no-follow create so a planted or
|
||
# raced symlink at the marker path can't redirect the write outside
|
||
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
|
||
# symlink, so we never write through one.
|
||
try:
|
||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||
fd = os.open(marker, flags, 0o644)
|
||
try:
|
||
os.write(fd, b"1\n")
|
||
finally:
|
||
os.close(fd)
|
||
except FileExistsError:
|
||
pass # already marked (or a non-regular path is squatting) — fine
|
||
except OSError as exc:
|
||
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
|
||
except Exception:
|
||
log.warning("Starter content seed: unexpected error", exc_info=True)
|
||
|
||
|
||
def _background_scan():
|
||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||
|
||
Never sets `_scan_status["running"] = False` — ownership of that flag
|
||
lives in `_scan_runner` so a `_kick_scan()` racing this function's
|
||
terminal write cannot observe a stale False and start a second runner.
|
||
"""
|
||
global _scan_status
|
||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "listing"}
|
||
|
||
# Load config once so both the DLC-dir lookup and the platform filter
|
||
# read from the same snapshot, avoiding a redundant parse of config.json.
|
||
_cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings()
|
||
dlc = _get_dlc_dir(_cfg)
|
||
if not dlc:
|
||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "idle", "error": "DLC folder not configured"}
|
||
log.warning("Scan: no DLC folder configured")
|
||
return
|
||
|
||
_seed_builtin_diagnostic_sloppaks(dlc)
|
||
_seed_builtin_starter_content(dlc)
|
||
|
||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||
# path isn't shared. Report the failure explicitly rather than silently
|
||
# appearing to scan nothing.
|
||
try:
|
||
# Generated-content sloppaks that the highway WS must resolve by path
|
||
# but that are NOT library songs. Two conventions share this carve-out:
|
||
# - tutorials-builtin/ — lesson drills seeded by the tutorials plugin
|
||
# (see plugins/tutorials/routes.py::_seed_builtin_packs).
|
||
# - minigames-builtin/ — exercise charts generated on demand by
|
||
# minigame plugins (e.g. Chord Sprint writes alternating-chord
|
||
# drills here). Cached/reused per exercise, never browsed.
|
||
# Both are kept out of the scan; _resolve_dlc_path still loads them by
|
||
# path for playback.
|
||
def _is_excluded_from_library(p: Path) -> bool:
|
||
return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts
|
||
# Sloppaks: match both file (zip) and directory form, across both the
|
||
# `.feedpak` and legacy `.sloppak` suffixes.
|
||
_cands = sorted(p for ext in sloppak_mod.SONG_EXTS for p in dlc.rglob(f"*{ext}"))
|
||
sloppaks = [f for f in _cands
|
||
if sloppak_mod.is_sloppak(f)
|
||
and not _is_excluded_from_library(f)]
|
||
|
||
# Loose song folders: any directory containing a non-preview *.wem + *.xml.
|
||
# Skip directories that are actually sloppak bundles — those are
|
||
# already in `sloppaks`; the dispatcher's sloppak-first precedence
|
||
# would route them to the sloppak path anyway, but adding them
|
||
# here would inflate the scan queue and over-count the total.
|
||
loose_songs = []
|
||
seen_loose = set()
|
||
sloppak_dirs = {p for p in sloppaks if p.is_dir()}
|
||
for wem in sorted(dlc.rglob("*.wem")):
|
||
if "preview" in wem.stem.lower():
|
||
continue
|
||
if _is_excluded_from_library(wem):
|
||
continue
|
||
d = wem.parent
|
||
if d in sloppak_dirs or d.name.lower().endswith(sloppak_mod.SONG_EXTS):
|
||
continue
|
||
if d not in seen_loose and loosefolder_mod.is_loose_song(d):
|
||
loose_songs.append(d)
|
||
seen_loose.add(d)
|
||
except PermissionError as e:
|
||
msg = (f"Permission denied reading {dlc}. "
|
||
"On macOS: grant Full Disk Access to the app in System Settings → Privacy & Security. "
|
||
"With Docker: share this path in Docker Desktop → Settings → Resources → File Sharing.")
|
||
log.error("Scan failed: %s (%s)", msg, e)
|
||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": msg}
|
||
return
|
||
except OSError as e:
|
||
log.error("Scan failed listing %s: %s", dlc, e)
|
||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": f"Unable to list {dlc}: {e}"}
|
||
return
|
||
|
||
all_songs = sloppaks + loose_songs
|
||
log.info("Scan: listed %d sloppaks and %d loose folders in %s",
|
||
len(sloppaks), len(loose_songs), dlc)
|
||
|
||
current_files = {_relpath(f, dlc) for f in all_songs}
|
||
|
||
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned
|
||
# + genuinely-new files) so the scan can surface an added/removed summary.
|
||
_delta = meta_db.delete_missing(current_files)
|
||
removed, added = _delta["removed"], _delta["added"]
|
||
if removed:
|
||
log.info("Removed %d stale DB entries", removed)
|
||
|
||
# Figure out which need scanning
|
||
to_scan = []
|
||
for f in all_songs:
|
||
# Skip entries that vanish or become unreadable between listing
|
||
# and stat. Without this, one concurrent move/delete in DLC_DIR
|
||
# would crash the scan thread and leave `_scan_status["running"]`
|
||
# stuck true with no path to recover.
|
||
try:
|
||
mtime, size = _stat_for_cache(f)
|
||
except OSError as e:
|
||
log.debug("scan: skipping %s (%s)", f, e)
|
||
continue
|
||
cache_key = _relpath(f, dlc)
|
||
try:
|
||
cached = meta_db.get(cache_key, mtime, size)
|
||
except Exception as e:
|
||
# Keep scanning even if a single metadata lookup fails.
|
||
# The file will be re-scanned and cache repaired by put().
|
||
log.warning("scan cache lookup failed for %s: %s", cache_key, e)
|
||
cached = None
|
||
if not cached:
|
||
to_scan.append((f, mtime, size, dlc))
|
||
elif cached.get("arrangements") and any(
|
||
"smart_name" not in a for a in cached["arrangements"]
|
||
):
|
||
# Row was scanned before smart naming was introduced — force a
|
||
# rescan so the DB picks up authoritative path flags from the
|
||
# manifest JSON and stores correct smart_name values. Don't
|
||
# re-queue rows where smart_name is explicitly null: the writer
|
||
# only emits that when compute_smart_names truly can't classify
|
||
# the arrangement (e.g. a name outside the recognised set with
|
||
# zero path flags), so rescanning would produce the same null
|
||
# forever and never converge.
|
||
to_scan.append((f, mtime, size, dlc))
|
||
|
||
if not to_scan:
|
||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
|
||
return
|
||
|
||
# Refine: all discovered songs need scanning → treat as first-time import
|
||
# (covers moved DLC folder / fully-stale DB as well as a genuinely empty DB).
|
||
is_first_scan = bool(all_songs) and len(to_scan) == len(all_songs)
|
||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "scanning", "total": len(to_scan),
|
||
"is_first_scan": is_first_scan}
|
||
log.info("Library: %d sloppaks + %d loose folders, %d cached, %d to scan",
|
||
len(sloppaks), len(loose_songs), len(all_songs) - len(to_scan), len(to_scan))
|
||
|
||
with _make_scan_executor() as executor:
|
||
futures = {executor.submit(_scan_one, item): item[0].name for item in to_scan}
|
||
for future in concurrent.futures.as_completed(futures):
|
||
fname = futures[future]
|
||
try:
|
||
name, mtime, size, meta = future.result()
|
||
meta_db.put(name, mtime, size, meta)
|
||
except Exception as e:
|
||
log.warning("scan failed for %s: %s", fname, e)
|
||
_scan_status["done"] += 1
|
||
_scan_status["current"] = fname
|
||
|
||
log.info("Scan complete: %d songs cached", len(to_scan))
|
||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||
|
||
|
||
_scan_kick_lock = threading.Lock()
|
||
_scan_rescan_pending = False
|
||
|
||
# Handles to the running scan / enrichment worker threads. Both use the shared
|
||
# MetadataDB connection, so teardown/shutdown MUST join them before closing that
|
||
# connection — a daemon thread mid-query on a closed SQLite conn is a native
|
||
# use-after-free that segfaults the process (seen flaky in CI). Set by
|
||
# _kick_scan / _kick_enrich; joined by _join_background_db_threads().
|
||
_scan_thread: threading.Thread | None = None
|
||
|
||
|
||
def _join_background_db_threads(timeout: float = 30.0) -> None:
|
||
"""Block until the background scan + enrichment workers finish (or timeout).
|
||
|
||
A scan kicks enrichment on completion, so join the scan first — by the time
|
||
it returns, _kick_enrich() has set _enrich_thread — then join enrichment."""
|
||
st = _scan_thread
|
||
if st is not None and st.is_alive():
|
||
st.join(timeout)
|
||
et = enrichment._enrich_thread
|
||
if et is not None and et.is_alive():
|
||
et.join(timeout)
|
||
|
||
|
||
def _kick_scan() -> bool:
|
||
"""Request a library rescan, single-flight + coalescing.
|
||
|
||
Returns True if a new scan thread was started, False if one was already
|
||
running. In the latter case a follow-up pass is queued and runs as soon
|
||
as the current scan finishes so files landing mid-scan (e.g. an upload
|
||
that finalizes after the scan has already listed DLC_DIR) are not lost
|
||
until the next periodic pass. Multiple late-arriving requests coalesce
|
||
into a single follow-up.
|
||
"""
|
||
global _scan_rescan_pending, _scan_thread
|
||
with _scan_kick_lock:
|
||
if _scan_status["running"]:
|
||
_scan_rescan_pending = True
|
||
return False
|
||
# Mark running synchronously so a parallel _kick_scan() observes it
|
||
# before the worker thread has a chance to reassign _scan_status.
|
||
_scan_status["running"] = True
|
||
_scan_thread = threading.Thread(target=_scan_runner, daemon=True)
|
||
_scan_thread.start()
|
||
return True
|
||
|
||
|
||
def _scan_runner():
|
||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||
global _scan_rescan_pending
|
||
while True:
|
||
try:
|
||
_background_scan()
|
||
except Exception:
|
||
log.exception("background scan failed unexpectedly")
|
||
|
||
with _scan_kick_lock:
|
||
if not _scan_rescan_pending:
|
||
_scan_status["running"] = False
|
||
break
|
||
_scan_rescan_pending = False
|
||
_scan_status["running"] = True
|
||
# Enrichment rides scan completion (library-metadata design §6): the scan
|
||
# pool is a side-effect-free, no-network process pool by design, so
|
||
# enrichment is a SEPARATE post-scan pass — non-blocking, the library is
|
||
# usable immediately. The 5-minute periodic rescan re-kicks it, which is
|
||
# the natural low-priority retry hook.
|
||
enrichment._kick_enrich()
|
||
|
||
|
||
# ── Metadata enrichment worker (P7 plumbing + P8 matcher) ─────────────────────
|
||
# A single throttled daemon thread + queue, mirroring _kick_scan/_scan_runner
|
||
# (single-flight + coalescing; NOT a pool — external lookups are rate-limited
|
||
# to ~1/s, which makes a pool pointless). P7 shipped the lifecycle; P8 fills
|
||
# in the matcher (_enrich_one): local cache → manifest mbid/isrc exact keys →
|
||
# MusicBrainz text search, scored into auto/review/failed tiers by
|
||
# lib/mb_match.py. Wrong-match is worse than slow (design §5): medium
|
||
# confidence goes to the Match-Review queue, never straight to canonical.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── AcoustID audio fingerprinting (content-based identification) ──────────────
|
||
# Optional path: requires the Chromaprint `fpcalc` binary AND an AcoustID API
|
||
# key ($ACOUSTID_API_KEY). Both absent ⇒ graceful no-op; the text matcher runs.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def _art_safe_name(filename: str) -> str:
|
||
"""The flattened cache-file stem the art routes key user overrides on
|
||
(matches the legacy /art/upload naming, so old uploads keep working)."""
|
||
return filename.replace("/", "_").replace(" ", "_")
|
||
|
||
|
||
def _art_override_paths(filename: str) -> list[Path]:
|
||
"""Existing user-override art files for a song, GIF first (it wins —
|
||
the animated local-only bonus outranks a stale PNG)."""
|
||
stem = _art_safe_name(filename)
|
||
return [p for p in (ART_CACHE_DIR / f"{stem}.gif", ART_CACHE_DIR / f"{stem}.png")
|
||
if p.is_file()]
|
||
|
||
|
||
def _song_pack_art_exists(filename: str) -> bool:
|
||
"""Whether the song carries its own art (sloppak cover / loose-folder
|
||
image). Pack art always outranks a CAA fetch, so the art worker marks
|
||
these and never spends a request on them."""
|
||
try:
|
||
dlc = _get_dlc_dir()
|
||
if not dlc:
|
||
return False
|
||
p = _resolve_dlc_path(dlc, filename)
|
||
if p is None or not p.exists():
|
||
return False
|
||
if sloppak_mod.is_sloppak(p):
|
||
return sloppak_mod.read_cover_bytes(p) is not None
|
||
if loosefolder_mod.is_loose_song(p):
|
||
return loosefolder_mod.find_art(p) is not None
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
# Publish the art cache dir + the two shared art helpers to the enrichment seam
|
||
# (lib/enrichment.py's worker calls these; the defs stay here because the art /
|
||
# delete routes share them). configure() is idempotent/additive.
|
||
appstate.configure(
|
||
art_cache_dir=ART_CACHE_DIR,
|
||
song_pack_art_exists=_song_pack_art_exists,
|
||
art_override_paths=_art_override_paths,
|
||
art_safe_name=_art_safe_name,
|
||
)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Register plugin API endpoints (lightweight, before app starts) ───────────
|
||
from plugins import load_plugins, register_plugin_api
|
||
register_plugin_api(app)
|
||
|
||
# Plugin loading deferred to startup event (see below) to avoid blocking
|
||
# server startup when many plugins are installed.
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def startup_events():
|
||
# Safety net: re-apply the structlog pipeline in case the server was
|
||
# started directly via `uvicorn server:app` (without main.py). When
|
||
# running via `python main.py`, configure_logging() was already called
|
||
# before uvicorn.run(..., log_config=None), so uvicorn never calls its
|
||
# own dictConfig() and this call is effectively a no-op. When running
|
||
# the uvicorn CLI directly, uvicorn applies LOGGING_CONFIG before the
|
||
# ASGI startup hook fires, overwriting the uvicorn* handlers; this call
|
||
# restores them for all messages after "Waiting for application startup".
|
||
configure_logging()
|
||
|
||
loop = asyncio.get_running_loop()
|
||
global _event_loop
|
||
_event_loop = loop
|
||
|
||
# Test/CI escape hatch: tests that import the FastAPI app via TestClient
|
||
# don't need plugin loading or the background library scan, and those
|
||
# paths touch the user filesystem in ways that aren't safe under
|
||
# parallel test runs. Drive startup straight to a terminal "complete"
|
||
# phase so any frontend startup waiter that observes the lifespan also
|
||
# unblocks cleanly (the SSE/poll client treats only `complete` and
|
||
# `error` as terminal when `running` becomes false).
|
||
if _env_flag("FEEDBACK_SKIP_STARTUP_TASKS"):
|
||
log.info("[startup] Skipping plugin load and background scan")
|
||
# Tests pop `server` from sys.modules across runs, but the `plugins`
|
||
# module is not reloaded — so LOADED_PLUGINS can carry stale entries
|
||
# from a previous test's startup, which `/api/plugins` would then
|
||
# expose despite this branch reporting zero loaded plugins. Normal
|
||
# startup clears it inside load_plugins; do the same here under the
|
||
# same lock so this skip path matches that invariant.
|
||
from plugins import LOADED_PLUGINS, PENDING_PLUGINS, PLUGINS_LOCK
|
||
with PLUGINS_LOCK:
|
||
LOADED_PLUGINS.clear()
|
||
PENDING_PLUGINS.clear()
|
||
_set_startup_status(
|
||
running=False,
|
||
phase="complete",
|
||
message="Startup tasks skipped (FEEDBACK_SKIP_STARTUP_TASKS).",
|
||
error=None,
|
||
current_plugin="",
|
||
loaded=0,
|
||
total=0,
|
||
)
|
||
return
|
||
|
||
_set_startup_status(
|
||
running=True,
|
||
phase="starting",
|
||
message="Core server ready. Starting plugin loader...",
|
||
error=None,
|
||
)
|
||
|
||
plugin_context = {
|
||
"config_dir": CONFIG_DIR,
|
||
"get_dlc_dir": _get_dlc_dir,
|
||
# Pass the DLC-root resolver (not its result) so loose-folder
|
||
# metadata keeps its dlc-relative artist/album inference while the
|
||
# lookup stays lazy — archive/sloppak extraction never reads config.
|
||
# Plugins still call this with just a path.
|
||
"extract_meta": lambda p: _extract_meta_for_file(p, _get_dlc_dir),
|
||
"meta_db": meta_db,
|
||
"get_scan_status": lambda: dict(_scan_status),
|
||
"get_art_cache_dir": lambda: ART_CACHE_DIR,
|
||
"library_providers": library_providers,
|
||
"register_library_provider": register_library_provider,
|
||
"unregister_library_provider": unregister_library_provider,
|
||
"register_tuning_provider": register_tuning_provider,
|
||
"unregister_tuning_provider": unregister_tuning_provider,
|
||
"get_sloppak_cache_dir": lambda: SLOPPAK_CACHE_DIR,
|
||
"register_demo_janitor_hook": register_demo_janitor_hook,
|
||
# Unified XP service (fee[dB]ack v0.3.0). Plugins that award XP
|
||
# (minigames, tutorials, …) should feed the single core store via these
|
||
# instead of keeping a private XP curve. `award_xp` returns the new
|
||
# progress payload; `seed_xp` is a one-time migration of pre-unification
|
||
# XP from a plugin's own store.
|
||
"award_xp": lambda amount, source=None: (meta_db.award_xp(amount, source), meta_db.get_progress())[1],
|
||
"get_xp_progress": lambda: meta_db.get_progress(),
|
||
"seed_xp": lambda amount, marker="minigames": meta_db.seed_xp_once(amount, marker),
|
||
# Reset one source's contribution to the unified total (e.g. a minigames
|
||
# profile-reset). Returns the new progress payload.
|
||
"reset_xp": lambda source: meta_db.reset_source_xp(source),
|
||
# Progression engine (spec 010): the backend twin of the frontend
|
||
# `progression` capability's record-event command. Backend plugin code
|
||
# is trusted, so no type whitelist here (the HTTP intake enforces one);
|
||
# returns the toast-ready summary {challenges_completed,
|
||
# quests_completed, level_ups, calibration_completed, mastery_rank}.
|
||
"record_progression_event": lambda event_type, payload=None: meta_db.record_progression_event(
|
||
event_type, payload, _get_progression_content()
|
||
),
|
||
}
|
||
|
||
# Load plugins asynchronously so HTTP routes and the desktop window can
|
||
# come up immediately while heavy plugin imports/install steps continue.
|
||
_sync_mode = getenv_compat("FEEDBACK_SYNC_STARTUP", "").lower() in {"1", "true", "yes", "on"}
|
||
|
||
def _load_plugins_background():
|
||
try:
|
||
# Track all active plugin errors so that a `clear_error=True`
|
||
# event from a fallback recovery correctly restores any *other*
|
||
# plugin's still-unresolved failure rather than wiping the error
|
||
# field entirely.
|
||
#
|
||
# Using a single "last error" pointer was insufficient: if plugin A
|
||
# fails, then plugin B fails and later recovers, the recovery would
|
||
# overwrite the pointer with B's id — and then B's `error=None`
|
||
# clears the status to null even though A is still broken.
|
||
#
|
||
# With a dict (keyed by plugin_id, insertion-ordered) we can
|
||
# remove B's entry on recovery and restore the most recent remaining
|
||
# failure from A, giving an accurate picture of startup health.
|
||
_active_errors: dict[str, str] = {} # plugin_id -> error text
|
||
|
||
def _on_progress(event: dict):
|
||
total = int(event.get("total") or 0)
|
||
loaded = int(event.get("loaded") or 0)
|
||
plugin_id = event.get("plugin_id") or ""
|
||
message = event.get("message") or "Loading plugins..."
|
||
phase = event.get("phase") or "plugins-loading"
|
||
update: dict = dict(
|
||
running=True,
|
||
phase=phase,
|
||
message=message,
|
||
current_plugin=plugin_id,
|
||
loaded=loaded,
|
||
total=total,
|
||
)
|
||
# Forward the error field only when the event explicitly
|
||
# carries it. Two cases:
|
||
# - Non-null string: record this plugin's failure and display it.
|
||
# - Explicit null (clear_error=True in _emit_progress):
|
||
# remove this plugin's failure entry, then restore the most
|
||
# recently recorded still-active failure (if any) so
|
||
# unresolved failures from other plugins remain visible.
|
||
# An unscoped clear (no plugin_id) removes the unscoped
|
||
# sentinel and applies the same restore logic.
|
||
# Events that omit the key entirely leave the status unchanged,
|
||
# preserving any earlier plugin error across the many
|
||
# non-error progress events that follow normal setup steps.
|
||
if "error" in event:
|
||
err_val = event["error"]
|
||
if err_val is not None:
|
||
# Pop then re-insert so the key moves to the end of
|
||
# insertion order even when this plugin already has an
|
||
# entry. A plugin can emit more than one error during a
|
||
# single load (requirements + routes), and dict.update()
|
||
# on an existing key does NOT move it to the end, so
|
||
# remaining[-1] could return a stale earlier message
|
||
# after another plugin clears its own error.
|
||
_active_errors.pop(plugin_id, None)
|
||
_active_errors[plugin_id] = err_val
|
||
update["error"] = err_val
|
||
else:
|
||
# Clear this plugin's error entry (fallback recovery or
|
||
# unscoped clear), then surface the most recently added
|
||
# remaining failure, or None if all have been resolved.
|
||
_active_errors.pop(plugin_id, None)
|
||
remaining = list(_active_errors.values())
|
||
update["error"] = remaining[-1] if remaining else None
|
||
_set_startup_status(**update)
|
||
|
||
def _route_setup_on_main(fn):
|
||
"""Schedule plugin route registration on the event-loop thread.
|
||
|
||
FastAPI/Starlette router mutation is not thread-safe, so the
|
||
actual setup() call is normally marshalled back onto the event
|
||
loop via call_soon_threadsafe. The background thread blocks
|
||
until the registration completes, raises, or a 60 s timeout
|
||
elapses.
|
||
|
||
In synchronous startup mode (_sync_mode=True) this function is
|
||
called directly from the event-loop thread, so marshalling via
|
||
call_soon_threadsafe + fut.result() would deadlock (the loop
|
||
cannot drain the queued callback while it is blocked here).
|
||
In that case fn() is invoked inline instead.
|
||
|
||
On timeout (async mode only), startup continues normally. Any
|
||
exception that eventually arrives is logged via a done-callback
|
||
so it is never silently dropped.
|
||
"""
|
||
if _sync_mode:
|
||
# Already on the event-loop thread — call directly.
|
||
fn()
|
||
return
|
||
|
||
fut: concurrent.futures.Future = concurrent.futures.Future()
|
||
# _state_lock makes the "check _cancelled + set _started"
|
||
# transition in _do() atomic with the "read _started + set
|
||
# _cancelled" transition in the timeout handler. Without this
|
||
# lock the two threads can interleave:
|
||
#
|
||
# Thread A (_do): passes check-1, yields to event loop
|
||
# Thread B (timeout): reads _started=False → _mid_flight=False
|
||
# Thread A (_do): sets _started, passes check-2 → calls fn()
|
||
# Thread B (timeout): sets _cancelled (too late)
|
||
# Result: fn() runs AND fallback loads — concurrent mutation.
|
||
#
|
||
# With the lock, either _do() commits to running fn() before
|
||
# the timeout can set _cancelled (in which case _mid_flight=True
|
||
# and the fallback is skipped), or the timeout wins (sets
|
||
# _cancelled=True and reads _started=False → _mid_flight=False,
|
||
# then _do() sees _cancelled inside the lock and bails out).
|
||
_state_lock = threading.Lock()
|
||
_cancelled = threading.Event()
|
||
_started = threading.Event()
|
||
|
||
def _do():
|
||
with _state_lock:
|
||
if _cancelled.is_set():
|
||
# Timeout already fired before we started; bail
|
||
# to prevent a race with any fallback that may
|
||
# have been activated by load_plugins().
|
||
if not fut.done():
|
||
fut.set_result(None)
|
||
return
|
||
_started.set()
|
||
# Past the lock — committed to running fn().
|
||
try:
|
||
fn()
|
||
fut.set_result(None)
|
||
except Exception as exc:
|
||
fut.set_exception(exc)
|
||
|
||
loop.call_soon_threadsafe(_do)
|
||
try:
|
||
fut.result(timeout=60)
|
||
except concurrent.futures.TimeoutError as _te:
|
||
_pid = getattr(fn, "_plugin_id", "unknown")
|
||
# Read _started and set _cancelled atomically so _do()
|
||
# can't slip through the lock and start fn() between the
|
||
# two operations.
|
||
with _state_lock:
|
||
_mid_flight = _started.is_set()
|
||
_cancelled.set()
|
||
if _mid_flight:
|
||
log.warning(
|
||
"route registration for %r timed out after 60 s and "
|
||
"setup() was already mid-flight; any routes registered "
|
||
"before the timeout cannot be removed. The user-copy "
|
||
"fallback will NOT be activated to prevent concurrent "
|
||
"router mutation (Python threads cannot be interrupted "
|
||
"mid-execution). Restart the server to recover.",
|
||
_pid,
|
||
)
|
||
# Signal to load_plugins() that fallback is unsafe
|
||
# for this plugin — the original setup() is still
|
||
# running and may add more routes concurrently.
|
||
_te.setup_mid_flight = True
|
||
else:
|
||
log.warning(
|
||
"route registration for %r timed out after 60 s; "
|
||
"setup() had not started yet, so it has been cancelled "
|
||
"and the user-copy fallback (if any) can proceed safely.",
|
||
_pid,
|
||
)
|
||
# Prevent the still-queued _do() from executing if it
|
||
# hasn't started yet — avoids races with any fallback.
|
||
# Note: _cancelled was already set inside _state_lock above.
|
||
|
||
def _log_deferred(f: concurrent.futures.Future):
|
||
try:
|
||
exc = f.exception()
|
||
except concurrent.futures.CancelledError:
|
||
return
|
||
if exc is not None:
|
||
log.error("deferred route registration for %r raised: %s", _pid, exc)
|
||
|
||
fut.add_done_callback(_log_deferred)
|
||
raise # propagate to load_plugins() so it emits plugin-error and skips "Loaded routes"
|
||
|
||
_set_startup_status(
|
||
running=True,
|
||
phase="plugins-loading",
|
||
message="Loading plugins...",
|
||
current_plugin="",
|
||
loaded=0,
|
||
total=0,
|
||
error=None,
|
||
)
|
||
load_plugins(app, plugin_context, progress_cb=_on_progress,
|
||
route_setup_fn=_route_setup_on_main)
|
||
# Self-heal a freshly recreated container: its filesystem reset to
|
||
# the image-baked sheet (in-tree plugins only), but a mounted
|
||
# FEEDBACK_PLUGINS_DIR may carry user-installed plugins whose
|
||
# classes aren't in it. Run in its OWN daemon thread so the startup
|
||
# status can flip to "complete" immediately rather than waiting on
|
||
# the (up to 120s) Tailwind subprocess. No-op when there are no user
|
||
# plugins or no Tailwind engine (e.g. desktop/native).
|
||
def _startup_tailwind_rebuild():
|
||
try:
|
||
import tailwind_rebuild
|
||
if tailwind_rebuild.user_plugin_count() > 0:
|
||
tailwind_rebuild.rebuild("startup-scan")
|
||
except Exception:
|
||
log.warning("startup tailwind rebuild failed", exc_info=True)
|
||
|
||
# Skip entirely in sync-startup mode (used by tests): no background
|
||
# thread AND no slow inline subprocess. The startup self-heal only
|
||
# matters for a real async startup of a recreated container.
|
||
if not _sync_mode:
|
||
threading.Thread(target=_startup_tailwind_rebuild, daemon=True).start()
|
||
status = _get_startup_status()
|
||
_set_startup_status(
|
||
running=False,
|
||
phase="complete",
|
||
message="Startup complete",
|
||
current_plugin="",
|
||
loaded=status.get("loaded", 0),
|
||
total=max(status.get("total", 0), status.get("loaded", 0)),
|
||
error=status.get("error"),
|
||
)
|
||
except Exception as e:
|
||
_set_startup_status(
|
||
running=False,
|
||
phase="error",
|
||
message="Plugin startup failed",
|
||
error=str(e),
|
||
)
|
||
log.exception("plugin startup failed")
|
||
|
||
if _sync_mode:
|
||
# Caller requested synchronous startup (e.g. test environment).
|
||
# Run the loader inline so startup is complete before the server's
|
||
# startup handler returns — no polling or timing workarounds needed.
|
||
_load_plugins_background()
|
||
else:
|
||
threading.Thread(target=_load_plugins_background, daemon=True).start()
|
||
|
||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" and not _DEMO_JANITOR_STARTED:
|
||
_DEMO_JANITOR_STARTED = True
|
||
_DEMO_JANITOR_STOP.clear()
|
||
def _janitor():
|
||
while not _DEMO_JANITOR_STOP.wait(timeout=3600):
|
||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||
hooks = list(_DEMO_JANITOR_HOOKS)
|
||
for hook in hooks:
|
||
_run_janitor_hook(hook)
|
||
_DEMO_JANITOR_THREAD = threading.Thread(target=_janitor, daemon=True, name="demo-janitor")
|
||
_DEMO_JANITOR_THREAD.start()
|
||
|
||
# Start background metadata scan
|
||
startup_scan()
|
||
|
||
|
||
@app.on_event("shutdown")
|
||
def shutdown_events():
|
||
"""Stop the demo-mode janitor thread (if running) on server shutdown."""
|
||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _event_loop
|
||
_event_loop = None # prevent stale loop reference after shutdown
|
||
if _DEMO_JANITOR_STARTED:
|
||
_DEMO_JANITOR_STOP.set()
|
||
thread = _DEMO_JANITOR_THREAD
|
||
if thread is not None:
|
||
thread.join(timeout=5)
|
||
if thread.is_alive():
|
||
import warnings
|
||
warnings.warn(
|
||
"demo-janitor thread did not stop within 5 s; "
|
||
"a registered hook may be blocking",
|
||
RuntimeWarning,
|
||
stacklevel=1,
|
||
)
|
||
# Leave _DEMO_JANITOR_STARTED True so a new janitor is not
|
||
# spawned by a subsequent startup while the old one is alive.
|
||
return
|
||
_DEMO_JANITOR_THREAD = None
|
||
_DEMO_JANITOR_STARTED = False
|
||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||
_DEMO_JANITOR_HOOKS.clear()
|
||
|
||
|
||
def startup_scan():
|
||
"""Start background metadata scan and periodic rescan on server start."""
|
||
_kick_scan()
|
||
# Periodic rescan every 5 minutes
|
||
rescan_thread = threading.Thread(target=_periodic_rescan, daemon=True)
|
||
rescan_thread.start()
|
||
|
||
|
||
def _periodic_rescan():
|
||
"""Check for new files every 5 minutes."""
|
||
time.sleep(300) # Wait 5 minutes after startup
|
||
while True:
|
||
# _kick_scan() is a no-op (returns False, queues a pending pass) when
|
||
# a scan is already running, so racing against the active scan is
|
||
# safe — no second runner is spawned.
|
||
_kick_scan()
|
||
time.sleep(300)
|
||
|
||
|
||
|
||
|
||
# ── App version / source URLs ────────────────────────────────────────────────
|
||
# Mounted here (registration order). Implementation in lib/routers/version.py.
|
||
app.include_router(version.router)
|
||
|
||
|
||
@app.get("/api/scan-status")
|
||
def scan_status():
|
||
return _scan_status
|
||
|
||
|
||
# ── Enrichment routes → routers/enrichment.py (R3) ──────────────────────────
|
||
app.include_router(enrichment_routes.router)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
@app.get("/api/startup-status")
|
||
def startup_status():
|
||
return _get_startup_status()
|
||
|
||
|
||
@app.get("/api/startup-status/stream")
|
||
async def startup_status_stream(request: Request):
|
||
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1)
|
||
# Register before putting the initial snapshot. asyncio cooperative
|
||
# scheduling guarantees _put_latest cannot run between add() and the
|
||
# put() below: put() on an empty maxsize-1 queue never yields (CPython
|
||
# fast path), so no event-loop iteration fires in between. Registering
|
||
# first ensures a terminal status fired just after connect is never missed.
|
||
with _startup_sse_lock:
|
||
_startup_sse_subscribers.add(queue)
|
||
await queue.put(_get_startup_status())
|
||
|
||
async def _gen():
|
||
since_ka = 0.0
|
||
try:
|
||
while True:
|
||
try:
|
||
data = await asyncio.wait_for(queue.get(), timeout=_SSE_POLL_INTERVAL)
|
||
except asyncio.TimeoutError:
|
||
if await request.is_disconnected():
|
||
break
|
||
since_ka += _SSE_POLL_INTERVAL
|
||
if since_ka >= _SSE_KA_INTERVAL:
|
||
yield 'data: {"type":"keepalive"}\n\n'
|
||
since_ka = 0.0
|
||
continue
|
||
yield f"data: {json.dumps(data)}\n\n"
|
||
if not data.get("running", True):
|
||
break
|
||
since_ka = 0.0 # reset keepalive timer — a real event just went out
|
||
# Check after each delivered message so that rapid-fire updates
|
||
# don't prevent disconnect detection (the timeout path above only
|
||
# fires when the queue is idle for the full _SSE_POLL_INTERVAL).
|
||
if await request.is_disconnected():
|
||
break
|
||
finally:
|
||
with _startup_sse_lock:
|
||
_startup_sse_subscribers.discard(queue)
|
||
|
||
return StreamingResponse(
|
||
_gen(),
|
||
media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||
)
|
||
|
||
|
||
@app.post("/api/rescan")
|
||
def trigger_rescan():
|
||
"""Manually trigger a library rescan."""
|
||
if not _kick_scan():
|
||
return {"message": "Scan already in progress"}
|
||
return {"message": "Rescan started"}
|
||
|
||
|
||
@app.post("/api/rescan/full")
|
||
def trigger_full_rescan():
|
||
"""Clear cache and rescan everything."""
|
||
if _scan_status["running"]:
|
||
return {"message": "Scan already in progress"}
|
||
with meta_db._lock:
|
||
# Force every file to re-scan by invalidating the mtime cache (get()
|
||
# keys on mtime equality) WITHOUT emptying `songs` — keeping the rows
|
||
# means the table is never transiently empty mid-scan, so the
|
||
# existing-song stats/playlist read-filter stays correct throughout.
|
||
# delete_missing() prunes anything genuinely gone at the end.
|
||
meta_db.conn.execute("UPDATE songs SET mtime = -1")
|
||
meta_db.conn.commit()
|
||
if not _kick_scan():
|
||
return {"message": "Scan already in progress"}
|
||
return {"message": "Full rescan started"}
|
||
|
||
|
||
# ── Song upload ───────────────────────────────────────────────────────────────
|
||
|
||
_ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS)
|
||
|
||
|
||
|
||
|
||
def _invalidate_song_caches(cache_key: str) -> None:
|
||
"""Drop filename-keyed derived caches when a song at ``cache_key`` is
|
||
replaced or removed. Sloppak's ``_source_cache`` and loose-folder audio
|
||
IDs self-invalidate via stat checks; the caches purged here do not."""
|
||
# In-memory archive extraction cache (filename → tmp dir + Song).
|
||
with _extract_cache_lock:
|
||
stale = _extract_cache.pop(cache_key, None)
|
||
if stale:
|
||
shutil.rmtree(stale[0], ignore_errors=True)
|
||
|
||
# Art cache — match the safe_name mapping used by get_song_art /
|
||
# upload_song_art_b64 exactly so we hit the same on-disk file.
|
||
safe_name = cache_key.replace("/", "_").replace(" ", "_")
|
||
art_file = ART_CACHE_DIR / f"{safe_name}.png"
|
||
try:
|
||
art_file.unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
except OSError:
|
||
log.debug("failed to evict art cache for %s", cache_key, exc_info=True)
|
||
|
||
# archive audio cache — audio_id is `Path(filename).stem.replace(" ", "_")`
|
||
# without any stat digest, so a same-named replacement would serve the
|
||
# previous file's converted audio. Loose-folder ids include a wem stat
|
||
# digest and self-heal; sloppak streams stems directly and uses no
|
||
# audio_id at all — both safely no-op here.
|
||
audio_id = Path(cache_key).stem.replace(" ", "_")
|
||
for d in (AUDIO_CACHE_DIR, STATIC_DIR):
|
||
for ext in (".mp3", ".ogg", ".wav"):
|
||
f = d / f"audio_{audio_id}{ext}"
|
||
try:
|
||
f.unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
except OSError:
|
||
log.debug("failed to evict audio cache file %s", f, exc_info=True)
|
||
|
||
|
||
# Publish the scan/ingest seam for routers/song.py. These stay here (the scan
|
||
# lifecycle owns them); scan_status is a getter so the reassigned dict stays live.
|
||
appstate.configure(
|
||
kick_scan=_kick_scan,
|
||
invalidate_song_caches=_invalidate_song_caches,
|
||
stat_for_cache=_stat_for_cache,
|
||
scan_status=lambda: _scan_status,
|
||
)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Library API ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Multi-chart work grouping API (P5b) ──────────────────────────────────────
|
||
# Read + manage the charts of a work (the P5d Charts drawer consumes this). The
|
||
# grouping engine lives in MetadataDB (P5a); these are its HTTP surface. Local
|
||
# library only. NOTE: a scoped "work changed" repaint broadcast for OTHER open
|
||
# views is deferred to P5d — there's no server-side library event bus today, and
|
||
# the drawer updates itself from these responses.
|
||
|
||
# ── Small library / user-state endpoints (work prefs, favorites, tags, saved, session)
|
||
# Mounted here; implementation in lib/routers/library_extras.py. Paths are all
|
||
# distinct, so registering them together does not change routing.
|
||
app.include_router(library_extras.router)
|
||
|
||
|
||
# ── Chart-level endpoints (split/work/fileinfo) ──────────────────────────────
|
||
# Mounted here (registration order). Implementation in lib/routers/chart.py.
|
||
app.include_router(chart.router)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Personal per-song metadata (difficulty / notes / tags) ───────────────────
|
||
# The local, never-shared layer. Distinct from POST /api/song/{f}/meta, which
|
||
# writes catalog fields (title/artist/album/year) BACK INTO the feedpak file;
|
||
# these endpoints are DB-only and never touch the file. Likes stay the heart
|
||
# (POST /api/favorites/toggle).
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Artist aliases / Tidy-up (P4) ────────────────────────────────────────────
|
||
# Mounted here, where these routes used to be defined (FastAPI matches in
|
||
# registration order). Implementation in lib/routers/artist_aliases.py.
|
||
app.include_router(artist_aliases.router)
|
||
|
||
|
||
# ── Artist pages (launch charrette PR-B) ──────────────────────────────────────
|
||
# GET page = 100% local (renders offline, renders unmatched); GET links = the
|
||
# ONE lazy MusicBrainz artist lookup, cached forever in artist_enrichment and
|
||
# re-fetched only by the explicit refresh. Both links routes are demo-blocked
|
||
# (they store server state + spend the shared MB rate limit).
|
||
|
||
# ── Artist routes → routers/artist.py (R3) ──────────────────────────────────
|
||
app.include_router(artist_router.router)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Player profile (identity / avatars / progress) ───────────────────────────
|
||
# Mounted here (registration order). Implementation in lib/routers/profile.py.
|
||
app.include_router(profile.router)
|
||
|
||
|
||
# ── Gameplay scoring: XP award + per-song practice stats ─────────────────────
|
||
# Mounted here (registration order; /api/stats/{path} is last inside the router).
|
||
# Implementation in lib/routers/stats.py.
|
||
app.include_router(stats.router)
|
||
|
||
|
||
# ── Progression (spec 010) ───────────────────────────────────────────────────
|
||
# Mounted here (registration order). Implementation in lib/routers/progression.py.
|
||
app.include_router(progression.router)
|
||
|
||
|
||
# ── Cosmetics shop (spec 010) ────────────────────────────────────────────────
|
||
# Mounted here (registration order). Implementation in lib/routers/shop.py.
|
||
app.include_router(shop.router)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Playlists / custom covers (fee[dB]ack v0.3.0) ─────────────────────────────
|
||
# Mounted here, where these routes used to be defined (FastAPI matches in
|
||
# registration order). Implementation in lib/routers/playlists.py.
|
||
app.include_router(playlists.router)
|
||
|
||
|
||
# ── Smart collections API (feedBack#636 item 2) ───────────────────────────────
|
||
# (rule schema + `_sanitize_collection_rules` are defined with the provider.)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Wishlist / "wanted" API (feedBack#636 item 4) ─────────────────────────────
|
||
# Mounted here (registration order). Implementation in lib/routers/wanted.py.
|
||
app.include_router(wanted.router)
|
||
|
||
|
||
# ── Loops API ────────────────────────────────────────────────────────────────
|
||
# Mounted here, where these routes used to be defined (FastAPI matches in
|
||
# registration order). Implementation in lib/routers/loops.py.
|
||
app.include_router(loops.router)
|
||
|
||
|
||
# ── Audio Effects Mapping API ───────────────────────────────────────────────
|
||
# Mounted here, where these routes used to be defined: FastAPI matches in
|
||
# registration order, so the mount site preserves it.
|
||
app.include_router(audio_effects.router)
|
||
|
||
|
||
# ── Settings API ──────────────────────────────────────────────────────────────
|
||
|
||
# ── Settings routes → routers/settings.py (R3) ──────────────────────────────
|
||
app.include_router(settings_router.router)
|
||
|
||
|
||
def _default_settings():
|
||
"""Fallback settings returned when config.json is missing or
|
||
unreadable. Also used to seed a fresh cfg on first-run POSTs so a
|
||
single-key write (e.g. the difficulty slider) can't silently wipe
|
||
defaults that subsequent GETs would have exposed."""
|
||
# Same `_DLC_DIR_ENV` truthy check as `_get_dlc_dir`: an empty env
|
||
# var collapses to `Path(".")` whose `.is_dir()` is True, so without
|
||
# the explicit guard we'd surface `"."` to /api/settings — and any
|
||
# partial-update POST would then persist that into config.json,
|
||
# silently undoing the env-var fix on the next load.
|
||
return {
|
||
"dlc_dir": str(DLC_DIR) if (_DLC_DIR_ENV and DLC_DIR.is_dir()) else "",
|
||
# fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). Each
|
||
# defaults to its neutral / off value so existing users see no
|
||
# behaviour change until they opt in. countdown_before_song is wired
|
||
# into the song-start path; miss_penalty / fail_behavior are persisted
|
||
# but not yet consumed by scoring (stub rows on the Gameplay tab).
|
||
"countdown_before_song": False,
|
||
"miss_penalty": "none",
|
||
"fail_behavior": "continue",
|
||
# Achievements epic: opt-in to publishing earned Feats (name + Feat id
|
||
# only) to the hosted wall. Default OFF — nothing leaves the device
|
||
# until the user opts in. Read by the bundled achievements plugin to
|
||
# gate its wall-sync enqueue.
|
||
"achievements_enabled": False,
|
||
# Amp-sim opt-in (issue feedBack-desktop#46). Whether the desktop app may
|
||
# auto-load an in-app amp-sim / tone chain (NAM / IR / VST) for input
|
||
# monitoring. Default OFF — "own-rig first": players monitoring through
|
||
# their own external amp/rig never get a processed monitor (and never the
|
||
# idle distorted buzz) until they opt in. Set during onboarding (desktop
|
||
# only) and from the desktop Audio settings toggle; read by the desktop
|
||
# renderer to gate its saved-chain restore. Inert on the pure-web build,
|
||
# which has no native amp sims.
|
||
"use_amp_sims": False,
|
||
# Metadata matching (P8). `enrich_enabled` gates only the BACKGROUND
|
||
# matcher — manual Fix-match/search in the review modal keeps working
|
||
# when it's off (the media-server model: scraper off ≠ no manual fix);
|
||
# the FEEDBACK_ENRICH_OFFLINE env var is the hard everything-off kill.
|
||
# `enrich_auto_threshold` is the auto-apply confidence — matches at or
|
||
# above it canonicalize automatically, below it queue for review. The
|
||
# per-field floors in lib/mb_match.py always apply on top, so lowering
|
||
# this can't make a wrong-artist cover auto-match. >1.0 (the "Always
|
||
# review" option) sends every text match to review.
|
||
"enrich_enabled": True,
|
||
"enrich_auto_threshold": 0.9,
|
||
# Scraper options (R1). Two axes, media-server style: sources say WHO
|
||
# may be contacted (MusicBrainz = the matcher, Cover Art Archive = the
|
||
# art fetch); the apply toggles say WHICH fields an AUTOMATIC match may
|
||
# canonicalize. A match the user confirms in the review modal always
|
||
# applies in full — these gate only what happens without them. All of
|
||
# it is display-side cache; nothing here ever writes to a pack file.
|
||
"enrich_src_musicbrainz": True,
|
||
"enrich_src_caa": True,
|
||
"enrich_apply_names": True,
|
||
"enrich_apply_year": True,
|
||
"enrich_apply_genres": True,
|
||
"enrich_apply_art": True,
|
||
# Review-queue ordering: missing_first = charts lacking album/year
|
||
# surface first (they gain the most), artist = A–Z, recent = newest
|
||
# files first.
|
||
"enrich_review_order": "missing_first",
|
||
# Artist pages (PR-B). The page itself is 100% local (renders from
|
||
# your own library rows), so it defaults ON; the external-links row
|
||
# (official site / tour dates / videos / social, one throttled
|
||
# MusicBrainz artist lookup per matched artist) is opt-IN — default
|
||
# OFF per the dev-chat thread. Links are links-only forever: always
|
||
# the external browser, never media delivered in-app.
|
||
"artist_pages_enabled": True,
|
||
"artist_external_links": False,
|
||
# Audio fingerprinting (AcoustID + Chromaprint). OPT-IN, default OFF.
|
||
# Text matching (MusicBrainz) can't reliably pick the exact recording
|
||
# for a song with many comp/live/reissue takes (especially a
|
||
# non-title-track — the title can't find the album); fingerprinting
|
||
# reads the audio itself and resolves the EXACT recording. Needs the
|
||
# user's own free AcoustID application key
|
||
# (https://acoustid.org/new-application) plus the `fpcalc` binary. The
|
||
# key lives here (settings) — not only an env var — so a user can set it
|
||
# themselves in the UI; $ACOUSTID_API_KEY stays a server-wide fallback.
|
||
"acoustid_enabled": False,
|
||
"acoustid_api_key": "",
|
||
}
|
||
|
||
|
||
# _default_settings stays here (the scan + artist-links code share it); publish
|
||
# it to the seam so routers/settings.py can build the same defaults.
|
||
appstate.configure(default_settings=_default_settings)
|
||
|
||
|
||
# GET /api/tunings → routers/tunings.py (R3, reads config + appstate.tuning_providers)
|
||
app.include_router(tunings_router.router)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Settings export/import (feedBack#113) ───────────────────────────────────
|
||
|
||
|
||
|
||
def _running_version() -> str:
|
||
"""Same lookup chain `/api/version` uses, factored out so the export
|
||
bundle records what shipped this file. Kept as a helper so future
|
||
changes (e.g. baked-in version) only have to touch one site."""
|
||
env_version = os.environ.get("APP_VERSION", "").strip()
|
||
if env_version:
|
||
return env_version
|
||
version_file = Path(__file__).parent / "VERSION"
|
||
if version_file.exists():
|
||
try:
|
||
return version_file.read_text().strip()
|
||
except (OSError, UnicodeDecodeError):
|
||
pass
|
||
return "unknown"
|
||
|
||
|
||
# _running_version is defined below the import-top configure() calls, so publish
|
||
# it here (configure is idempotent/additive) for routers/diagnostics.py.
|
||
appstate.configure(running_version=_running_version)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Diagnostic bundle export (feedBack#166) → routers/diagnostics.py (R3) ────
|
||
# The pure caps/normalisers are re-exported so existing server._diag_* /
|
||
# server._DIAG_* tests keep resolving (none of them monkeypatch these).
|
||
from routers.diagnostics import ( # noqa: E402 (re-export for test compatibility)
|
||
_diag_cap_console, _diag_cap_contributions, _diag_cap_dict,
|
||
_diag_coerce_bool, _diag_normalize_include,
|
||
_DIAG_MAX_CLIENT_PAYLOAD_BYTES, _DIAG_MAX_CONSOLE_BYTES,
|
||
_DIAG_MAX_CONSOLE_ENTRIES, _DIAG_MAX_CONTRIBUTIONS_BYTES,
|
||
)
|
||
app.include_router(diagnostics.router)
|
||
|
||
|
||
# ── Plugin-provided routes are registered at startup via plugins/__init__.py ─
|
||
# (CustomsForge, Ultimate Guitar, etc. are loaded from plugins/ directory)
|
||
|
||
|
||
|
||
# ── Album-art routes → routers/art.py (R3) ──────────────────────────────────
|
||
app.include_router(art_router.router)
|
||
# Song routes mount AFTER art (and every other /api/song/{path}/… route): its
|
||
# get_song_info catch-all `/api/song/{filename:path}` would otherwise shadow them.
|
||
app.include_router(song_router.router)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ── Highway WebSocket ─────────────────────────────────────────────────────────
|
||
|
||
# Filename-keyed extraction cache, retained so _invalidate_song_caches() has a
|
||
# stable handle to purge on song replace/delete. Open formats (sloppak/loose)
|
||
# self-invalidate via stat checks and never populate this, so it stays empty in
|
||
# practice.
|
||
_extract_cache = {} # filename -> (tmp_dir, song, timestamp)
|
||
_extract_cache_lock = threading.Lock()
|
||
|
||
|
||
# ── Media/file-serving routes → routers/media.py (R3) ───────────────────────
|
||
app.include_router(media_router.router)
|
||
|
||
|
||
|
||
|
||
# ── Highway chart WebSocket ──────────────────────────────────────────────────
|
||
# Mounted here, where the handler used to be defined (registration order).
|
||
# Implementation in lib/routers/ws_highway.py.
|
||
app.include_router(ws_highway.router)
|
||
|
||
|
||
# ── Audio serving ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
|
||
|
||
|
||
|
||
class _RevalidatedStaticFiles(StaticFiles):
|
||
"""StaticFiles that forces conditional revalidation on every request.
|
||
|
||
Without Cache-Control, Chromium applies heuristic freshness (10% of the
|
||
file's age since Last-Modified) and serves /static/app.js from its disk
|
||
cache for hours-to-days without asking the server. In the desktop app that
|
||
meant a new build's renderer ran the PREVIOUS build's app.js — the
|
||
2026-07-11 ASIO investigation lost a day to a stale loader that couldn't
|
||
even load module plugins. `no-cache` does NOT disable caching: the browser
|
||
keeps the cached copy and revalidates with If-None-Match; unchanged files
|
||
still cost only a 304."""
|
||
|
||
async def get_response(self, path, scope):
|
||
response = await super().get_response(path, scope)
|
||
response.headers.setdefault("Cache-Control", "no-cache")
|
||
return response
|
||
|
||
|
||
app.mount("/static", _RevalidatedStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||
|
||
|
||
@app.get("/")
|
||
def index():
|
||
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
|
||
|
||
|
||
@app.get("/v3")
|
||
def index_v3():
|
||
# Retained as a back-compat alias for links minted while v3 was opt-in.
|
||
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
|