mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:54:31 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12b459fcc | ||
|
|
36d984fffd | ||
|
|
f8fc5e6a5f | ||
|
|
803193046e | ||
|
|
702a9c6daa | ||
|
|
99b6d3c384 | ||
|
|
0cc08ebebf | ||
|
|
a2a48b3912 | ||
|
|
05a9bee38f | ||
|
|
f5d448af5c | ||
|
|
8f014e6a30 | ||
|
|
6b8f79dd9a |
@@ -24,6 +24,9 @@ plugins/*/
|
||||
!plugins/achievements/
|
||||
!plugins/achievements/**
|
||||
plugins/achievements/__pycache__/
|
||||
!plugins/career/
|
||||
!plugins/career/**
|
||||
plugins/career/__pycache__/
|
||||
!plugins/highway_3d/
|
||||
!plugins/highway_3d/**
|
||||
plugins/highway_3d/__pycache__/
|
||||
|
||||
@@ -117,6 +117,16 @@ invalidate_song_caches = None
|
||||
stat_for_cache = None
|
||||
scan_status = None
|
||||
|
||||
# The directory containing server.py: the repo root in dev, resources/feedBack when
|
||||
# bundled — the tree that actually holds docs/ and data/.
|
||||
#
|
||||
# It is published HERE, by server.py, precisely so no module under lib/ ever computes it.
|
||||
# `Path(__file__).resolve().parent` is correct in server.py and silently WRONG anywhere in
|
||||
# lib/ (it yields lib/, which has no docs/ or data/), and it fails by finding nothing
|
||||
# rather than by raising — the builtin-content seeds would just quietly never run. See
|
||||
# lib/builtin_content.py's header. Read it; never re-derive it.
|
||||
server_root = None
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings", "tuning_providers",
|
||||
"library_providers", "local_library_provider",
|
||||
@@ -127,6 +137,7 @@ _SLOTS = frozenset({
|
||||
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
|
||||
"default_settings",
|
||||
"kick_scan", "invalidate_song_caches", "stat_for_cache", "scan_status",
|
||||
"server_root",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Demo mode: the read-only request guard and the hourly session janitor.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b). Bodies are byte-identical — including a bug, see
|
||||
below.
|
||||
|
||||
━━━ THE MIDDLEWARE NEEDS `app`, SO THIS MODULE TAKES IT ━━━
|
||||
|
||||
`_demo_mode_guard` is an @app.middleware("http"), and a middleware has to be attached to an
|
||||
app object. Rather than reach for a global, this module exposes install(app): server.py
|
||||
owns the app and hands it over. Same direction as every other seam here — server.py knows
|
||||
things lib/ must not have to guess.
|
||||
|
||||
The janitor is symmetrical: start_janitor() / stop_janitor(), called from server.py's
|
||||
startup and shutdown hooks, which is where the process lifecycle actually lives.
|
||||
|
||||
━━━ register_demo_janitor_hook IS PART OF THE PLUGIN CONTRACT ━━━
|
||||
|
||||
It is a key in plugin_context, so plugins hold it as a LIVE REFERENCE from setup(). Moving
|
||||
the function is fine; wrapping or renaming it is not. server.py imports this exact object
|
||||
and puts it in the dict unchanged, so callable identity is preserved —
|
||||
tests/test_plugin_context_contract.py (#898) fails if that ever stops being true.
|
||||
|
||||
━━━ A BUG MOVED VERBATIM, ON PURPOSE ━━━
|
||||
|
||||
The janitor start guard in server.py reads:
|
||||
|
||||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
|
||||
and not _DEMO_JANITOR_STARTED:
|
||||
|
||||
`and` binds tighter than `or`, so that is `A or (B and C)` — the `not _DEMO_JANITOR_STARTED`
|
||||
re-entry guard is DEAD whenever the env var is truthy, which is the only case that runs. A
|
||||
second startup leaks a janitor thread (the handle is overwritten, so shutdown joins only
|
||||
the last). Preserved exactly as-is here and filed as issue #902: a carve whose value is
|
||||
being provably behaviour-neutral is not the place to change behaviour.
|
||||
"""
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.demo_mode")
|
||||
|
||||
|
||||
# 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$")),
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def install(app) -> None:
|
||||
"""Attach the demo-mode request guard to `app`.
|
||||
|
||||
Called by server.py, which owns the app. A middleware cannot exist without one, and a
|
||||
module under lib/ should not be reaching for a global to find it.
|
||||
"""
|
||||
app.middleware("http")(_demo_mode_guard)
|
||||
|
||||
|
||||
def demo_mode_enabled() -> bool:
|
||||
"""True when demo mode is on. Read at CALL time, never captured — tests set and unset
|
||||
FEEDBACK_DEMO_MODE with monkeypatch, so a value cached at import pins the wrong one."""
|
||||
return bool(getenv_compat("FEEDBACK_DEMO_MODE"))
|
||||
|
||||
|
||||
def start_janitor() -> None:
|
||||
"""Start the hourly session janitor. Called from server.py's startup hook.
|
||||
|
||||
NB the caller's guard is the buggy one described in this module's header (issue #902).
|
||||
Behaviour is preserved verbatim: this starts a thread every time it is called.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||||
_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()
|
||||
|
||||
|
||||
def janitor_started() -> bool:
|
||||
return _DEMO_JANITOR_STARTED
|
||||
|
||||
|
||||
def stop_janitor(timeout: float = 5) -> bool:
|
||||
"""Signal the janitor to stop, join it, and drop the registered hooks.
|
||||
|
||||
Returns True if it stopped, False if it outlived the join (the caller warns).
|
||||
|
||||
THE ORDER HERE IS LOAD-BEARING and preserved exactly from server.py. When the thread
|
||||
does NOT die within the timeout we return WITHOUT clearing _DEMO_JANITOR_STARTED and
|
||||
WITHOUT dropping the thread handle — deliberately — so a subsequent startup does not
|
||||
spawn a SECOND janitor alongside the one still running. Clearing the flag first (the
|
||||
obvious way to write this) would quietly reintroduce exactly the double-janitor leak
|
||||
the flag exists to prevent.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||||
if not _DEMO_JANITOR_STARTED:
|
||||
return True
|
||||
_DEMO_JANITOR_STOP.set()
|
||||
thread = _DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
if thread.is_alive():
|
||||
# Leave _DEMO_JANITOR_STARTED True so a new janitor is not spawned by a
|
||||
# subsequent startup while the old one is alive.
|
||||
return False
|
||||
_DEMO_JANITOR_THREAD = None
|
||||
_DEMO_JANITOR_STARTED = False
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
_DEMO_JANITOR_HOOKS.clear()
|
||||
return True
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"""The library scanner: the background scan, its process pool, and the kick/runner
|
||||
plumbing that serialises passes.
|
||||
|
||||
Carved VERBATIM out of server.py (R3b) except the seam reads. Everything shared is read
|
||||
LATE off appstate — the same contract every module in lib/routers/ uses, and it is not
|
||||
cosmetic: tests monkeypatch CONFIG_DIR and swap meta_db, so a value captured at import
|
||||
time would pin the wrong one for the life of the process.
|
||||
|
||||
CONFIG_DIR -> appstate.config_dir
|
||||
meta_db -> appstate.meta_db
|
||||
_default_settings -> appstate.default_settings()
|
||||
_stat_for_cache -> appstate.stat_for_cache()
|
||||
_feedBack_server_root() -> appstate.server_root <- see below
|
||||
|
||||
━━━ THE SCAN STATUS IS REBOUND, NOT MUTATED ━━━
|
||||
|
||||
`_background_scan` does `global _scan_status; _scan_status = {**INIT, ...}` at every stage
|
||||
transition. It REPLACES the dict; it does not update it in place. So nothing may hold the
|
||||
dict by value — a reference captured once goes permanently stale at the first stage change,
|
||||
and would report "listing" forever while the scan ran to completion.
|
||||
|
||||
That is why this module exports `status()`, a getter, and why appstate publishes
|
||||
`scan_status` as a CALLABLE rather than a dict. appstate.py already says so in a comment;
|
||||
this is the code that makes it true.
|
||||
|
||||
━━━ AND WHY THE SERVER ROOT IS READ, NEVER DERIVED ━━━
|
||||
|
||||
`_background_scan` seeds the builtin content, which needs the directory holding server.py.
|
||||
`Path(__file__).resolve().parent` is correct in server.py and silently WRONG here (it
|
||||
yields lib/, which has no docs/ or data/) — and it fails by finding nothing rather than by
|
||||
raising, so the seeds would just quietly never run. server.py publishes the root once, as
|
||||
appstate.server_root. Read it; never re-derive it.
|
||||
"""
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
import builtin_content
|
||||
import enrichment
|
||||
import loosefolder as loosefolder_mod
|
||||
import sloppak as sloppak_mod
|
||||
from appconfig import _load_config
|
||||
from dlc_paths import _get_dlc_dir
|
||||
from env_compat import getenv_compat
|
||||
from scan_worker import _relpath, _scan_one
|
||||
|
||||
log = logging.getLogger("feedBack.scan")
|
||||
|
||||
|
||||
_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)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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(appstate.config_dir / "config.json") or appstate.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
|
||||
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
|
||||
builtin_content.seed_builtin_starter_content(appstate.server_root, 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 = appstate.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 = appstate.stat_for_cache(f)
|
||||
except OSError as e:
|
||||
log.debug("scan: skipping %s (%s)", f, e)
|
||||
continue
|
||||
cache_key = _relpath(f, dlc)
|
||||
try:
|
||||
cached = appstate.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()
|
||||
appstate.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 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()
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""The live scan status.
|
||||
|
||||
A GETTER, deliberately. `_scan_status` is REBOUND on every stage transition, so a
|
||||
caller holding the dict would be reading a snapshot frozen at whatever stage it
|
||||
happened to grab — see the module header.
|
||||
"""
|
||||
return _scan_status
|
||||
|
||||
|
||||
def scan_thread():
|
||||
"""The background scan thread, or None. Read by shutdown to join it."""
|
||||
return _scan_thread
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Career plugin — only what the prebuilt core Tailwind doesn't ship
|
||||
(plugin files are outside the core content glob, so responsive grid
|
||||
variants and cyan button shades live here under plugin-prefixed names). */
|
||||
|
||||
.career-venues {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.career-btn {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.career-btn-primary { background-color: #0891b2; color: #fff; }
|
||||
.career-btn-primary:hover { background-color: #06b6d4; }
|
||||
.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; }
|
||||
.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||
|
||||
.career-bar-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(31, 41, 55, 0.9);
|
||||
overflow: hidden;
|
||||
}
|
||||
.career-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #06b6d4;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.career-star-list {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.career-star-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.career-star-row .stars {
|
||||
color: #facc15;
|
||||
letter-spacing: 0.1em;
|
||||
min-width: 3.2em;
|
||||
}
|
||||
.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); }
|
||||
.career-star-row .song {
|
||||
color: #e5e7eb;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.career-star-row .song .artist { color: #9ca3af; }
|
||||
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
||||
.career-star-row .hint.close { color: #22d3ee; }
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "career",
|
||||
"name": "Career",
|
||||
"version": "0.1.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"description": "Career mode \u2014 gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/career.css",
|
||||
"routes": "routes.py",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "system"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Career mode — venue progression driven by per-song stars.
|
||||
|
||||
Stars come straight from ``song_stats`` (meta.db): per song, the best
|
||||
accuracy across arrangements crosses 0/1/2/3 of the thresholds in
|
||||
``venues.json`` (data-driven so tuning never touches code). Cumulative
|
||||
stars unlock venue tiers (bar → club → arena).
|
||||
|
||||
Venue packs (crowd-loop videos rendered offline in UE) are heavyweight and
|
||||
never ship with the app: ``venues.json`` points at a release asset per
|
||||
venue, downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues/
|
||||
<id>/`` on a background thread (constitution: nothing heavy inline on the
|
||||
request path), sha256-verified, then served back with the same
|
||||
FileResponse/no-cache recipe as highway_3d's custom-video route.
|
||||
|
||||
Endpoints (all under /api/plugins/career/):
|
||||
GET /state stars + per-venue unlock/install/download status
|
||||
POST /packs/{venue_id}/download start background pack download (409 if running)
|
||||
DELETE /packs/{venue_id} remove an installed pack
|
||||
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
PLUGIN_ID = "career"
|
||||
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
||||
DOWNLOAD_CHUNK = 1024 * 256
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state = {
|
||||
"content": None, # parsed venues.json
|
||||
"venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues
|
||||
"meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL)
|
||||
"log": logging.getLogger("feedBack.plugin.career"),
|
||||
"downloads": {}, # venue_id -> {status, bytes_done, bytes_total, error}
|
||||
}
|
||||
|
||||
|
||||
def _venue(venue_id):
|
||||
for v in _state["content"]["venues"]:
|
||||
if v["id"] == venue_id:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _venue_dir(venue_id) -> Path:
|
||||
return _state["venues_dir"] / venue_id
|
||||
|
||||
|
||||
def _installed(venue_id):
|
||||
return (_venue_dir(venue_id) / "manifest.json").is_file()
|
||||
|
||||
|
||||
def _stars():
|
||||
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return 0, {}, []
|
||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||
# Existing-song filter: a scan hides (not deletes) stats of songs removed
|
||||
# from the library, so orphaned rows must not keep counting toward stars.
|
||||
rows = db.conn.execute(
|
||||
"SELECT s.filename, MAX(s.best_accuracy), "
|
||||
" COALESCE(MAX(sg.title), ''), COALESCE(MAX(sg.artist), '') "
|
||||
"FROM song_stats s JOIN songs sg ON sg.filename = s.filename "
|
||||
"GROUP BY s.filename"
|
||||
).fetchall()
|
||||
per_song = {}
|
||||
detail = []
|
||||
for filename, acc, title, artist in rows:
|
||||
acc = acc or 0.0
|
||||
stars = sum(1 for t in thresholds if acc >= t)
|
||||
if stars:
|
||||
per_song[filename] = stars
|
||||
next_at = next((t for t in thresholds if acc < t), None)
|
||||
detail.append({
|
||||
"filename": filename,
|
||||
"title": title or filename,
|
||||
"artist": artist,
|
||||
"stars": stars,
|
||||
"best_accuracy": round(acc, 4),
|
||||
"next_star_at": next_at,
|
||||
})
|
||||
# closest-to-next-star first (a practice worklist), maxed songs last
|
||||
detail.sort(key=lambda r: (r["next_star_at"] is None,
|
||||
(r["next_star_at"] or 1.0) - r["best_accuracy"]))
|
||||
return sum(per_song.values()), per_song, detail
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||
manifest_path = pack_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
raise ValueError("pack has no manifest.json")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
loops = manifest.get("loops") or {}
|
||||
for state in REQUIRED_LOOPS:
|
||||
name = loops.get(state)
|
||||
if not name or not PACK_FILENAME_RE.fullmatch(name):
|
||||
raise ValueError(f"manifest is missing the '{state}' loop")
|
||||
if not (pack_dir / name).is_file():
|
||||
raise ValueError(f"loop file '{name}' missing from pack")
|
||||
for name in (manifest.get("stingers") or {}).values():
|
||||
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
|
||||
raise ValueError(f"stinger file '{name}' invalid or missing")
|
||||
for block in ("intro", "sfx"):
|
||||
for name in (manifest.get(block) or {}).values():
|
||||
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
|
||||
raise ValueError(f"{block} file '{name}' invalid or missing")
|
||||
|
||||
|
||||
def _download_pack(venue_id, pack, progress):
|
||||
"""Worker thread: stream → sha256 verify → extract → validate → swap in."""
|
||||
log = _state["log"]
|
||||
final_dir = _venue_dir(venue_id)
|
||||
staging = Path(tempfile.mkdtemp(prefix=f"career-{venue_id}-",
|
||||
dir=str(_state["venues_dir"])))
|
||||
zip_path = staging / "pack.zip"
|
||||
try:
|
||||
digest = hashlib.sha256()
|
||||
req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-career"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out:
|
||||
total = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0)
|
||||
progress["bytes_total"] = total
|
||||
while True:
|
||||
chunk = resp.read(DOWNLOAD_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
out.write(chunk)
|
||||
progress["bytes_done"] += len(chunk)
|
||||
if digest.hexdigest() != pack["sha256"]:
|
||||
raise ValueError("sha256 mismatch — corrupt or tampered download")
|
||||
|
||||
extract_dir = staging / "pack"
|
||||
extract_dir.mkdir()
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for info in zf.infolist():
|
||||
# Zip-slip guard: only flat, whitelisted names get extracted.
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = Path(info.filename).name
|
||||
if name != info.filename or not PACK_FILENAME_RE.fullmatch(name):
|
||||
raise ValueError(f"unexpected file in pack: {info.filename!r}")
|
||||
with zf.open(info) as src, open(extract_dir / name, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
zip_path.unlink()
|
||||
_validate_pack_dir(extract_dir)
|
||||
|
||||
if final_dir.exists():
|
||||
shutil.rmtree(final_dir)
|
||||
extract_dir.rename(final_dir)
|
||||
progress["status"] = "done"
|
||||
log.info("career: venue pack '%s' installed", venue_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
|
||||
progress["status"] = "error"
|
||||
progress["error"] = str(exc)
|
||||
log.warning("career: venue pack '%s' download failed: %s", venue_id, exc)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def setup(app, context):
|
||||
plugin_dir = Path(__file__).resolve().parent
|
||||
_state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8"))
|
||||
_state["venues_dir"] = (
|
||||
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
||||
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
||||
_state["meta_db"] = context.get("meta_db")
|
||||
_state["log"] = context.get("log") or _state["log"]
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
|
||||
def get_state():
|
||||
stars_total, per_song, star_detail = _stars()
|
||||
venues = []
|
||||
for v in _state["content"]["venues"]:
|
||||
with _lock:
|
||||
dl = dict(_state["downloads"].get(v["id"]) or {"status": "idle"})
|
||||
venues.append({
|
||||
"id": v["id"],
|
||||
"name": v["name"],
|
||||
"description": v.get("description", ""),
|
||||
"star_threshold": v["star_threshold"],
|
||||
"unlocked": stars_total >= v["star_threshold"],
|
||||
"installed": _installed(v["id"]),
|
||||
"has_pack": bool(v.get("pack")),
|
||||
"download": dl,
|
||||
})
|
||||
return {
|
||||
"stars_total": stars_total,
|
||||
"stars_per_song": per_song,
|
||||
"star_detail": star_detail,
|
||||
"star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"],
|
||||
"venues": venues,
|
||||
}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||
def start_download(venue_id: str):
|
||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||
if venue is None:
|
||||
raise HTTPException(404, "Unknown venue.")
|
||||
pack = venue.get("pack")
|
||||
if not pack:
|
||||
raise HTTPException(404, "No pack published for this venue yet.")
|
||||
stars_total, _, _ = _stars()
|
||||
if stars_total < venue["star_threshold"]:
|
||||
raise HTTPException(403, "Venue not unlocked yet.")
|
||||
with _lock:
|
||||
running = _state["downloads"].get(venue_id)
|
||||
if running and running["status"] == "running":
|
||||
raise HTTPException(409, "Download already running.")
|
||||
progress = {"status": "running", "bytes_done": 0,
|
||||
"bytes_total": pack.get("bytes") or 0, "error": None}
|
||||
_state["downloads"][venue_id] = progress
|
||||
threading.Thread(target=_download_pack, args=(venue_id, pack, progress),
|
||||
name=f"career-pack-{venue_id}", daemon=True).start()
|
||||
return {"ok": True}
|
||||
|
||||
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
|
||||
def delete_pack(venue_id: str):
|
||||
if not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None:
|
||||
raise HTTPException(404, "Unknown venue.")
|
||||
with _lock:
|
||||
running = _state["downloads"].get(venue_id)
|
||||
if running and running["status"] == "running":
|
||||
raise HTTPException(409, "Download in progress.")
|
||||
_state["downloads"].pop(venue_id, None)
|
||||
shutil.rmtree(_venue_dir(venue_id), ignore_errors=True)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}")
|
||||
async def get_pack_file(venue_id: str, filename: str):
|
||||
if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename):
|
||||
raise HTTPException(404, "Not found.")
|
||||
path = _venue_dir(venue_id) / filename
|
||||
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
|
||||
# the resolved path must stay inside the venues dir.
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(_state["venues_dir"].resolve())
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(404, "Not found.")
|
||||
if not resolved.is_file():
|
||||
raise HTTPException(404, "Not found.")
|
||||
media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg",
|
||||
"json": "application/json"}[resolved.suffix.lstrip(".").lower()]
|
||||
return FileResponse(
|
||||
resolved,
|
||||
media_type=media,
|
||||
# Pack files are immutable per version, but a re-download after a
|
||||
# pack update overwrites in place — no-cache + ETag revalidation
|
||||
# keeps browsers honest for the price of a 304.
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="max-w-5xl mx-auto px-4 py-6">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-1">
|
||||
<h1 class="text-2xl font-bold text-white">Career</h1>
|
||||
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
||||
<div id="career-progress-wrap" class="mb-6">
|
||||
<div class="career-bar-track">
|
||||
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
|
||||
</div>
|
||||
<div id="career-venues" class="career-venues"></div>
|
||||
<div class="mt-8">
|
||||
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
|
||||
<div id="career-star-summary" class="text-xs text-gray-400"></div>
|
||||
</div>
|
||||
<div id="career-star-list" class="career-star-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Career plugin — venue progression UI + crowd-manifest push.
|
||||
*
|
||||
* Reads /api/plugins/career/state (stars from song_stats, per-venue
|
||||
* unlock/install/download status), renders the career screen, and pushes the
|
||||
* active venue's pack manifest into the crowd video layer
|
||||
* (window.v3VenueCrowd, shipped with the venue crowd PR) whenever it changes.
|
||||
* Everything degrades: no crowd layer → screen still works; no packs → the
|
||||
* venue scene keeps its static plate.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API = '/api/plugins/career';
|
||||
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||
const NO_VENUE = '__none__';
|
||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||
const POLL_MS = 2000;
|
||||
|
||||
let _state = null;
|
||||
let _pollTimer = 0;
|
||||
let _appliedManifestVenue = null;
|
||||
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
|
||||
let _prevUnlockedIds = null;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
const res = await fetch(API + '/state');
|
||||
if (!res.ok) throw new Error('career state ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function lastOf(arr) { return arr.length ? arr[arr.length - 1] : null; }
|
||||
|
||||
// Active pack = localStorage override when unlocked+installed, else the
|
||||
// highest unlocked+installed tier; none → clear the crowd manifest.
|
||||
async function pushCrowdManifest(state) {
|
||||
const crowd = window.v3VenueCrowd;
|
||||
if (!crowd || typeof crowd.setManifest !== 'function') return;
|
||||
// Any newer invocation (delete, venue switch, fresher state) must win
|
||||
// over a manifest fetch still in flight from this one.
|
||||
const gen = ++_manifestReqGen;
|
||||
const unlocked = state.venues.filter((v) => v.unlocked);
|
||||
let venue = null;
|
||||
let override = null;
|
||||
try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ }
|
||||
if (override !== NO_VENUE) {
|
||||
venue = unlocked.find((v) => v.id === override && v.installed) || null;
|
||||
if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
|
||||
}
|
||||
if (!venue) {
|
||||
if (_appliedManifestVenue !== null) {
|
||||
_appliedManifestVenue = null;
|
||||
crowd.setManifest(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (venue.id === _appliedManifestVenue) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/venues/${venue.id}/manifest.json`);
|
||||
if (gen !== _manifestReqGen || !res.ok) return;
|
||||
const manifest = await res.json();
|
||||
if (gen !== _manifestReqGen) return;
|
||||
manifest.base = `${API}/venues/${venue.id}/`;
|
||||
_appliedManifestVenue = venue.id;
|
||||
crowd.setManifest(manifest);
|
||||
} catch (_) { /* pack half-installed; next refresh retries */ }
|
||||
}
|
||||
|
||||
function venueCardHTML(v, state) {
|
||||
const locked = !v.unlocked;
|
||||
const dl = v.download || { status: 'idle' };
|
||||
const pct = dl.bytes_total > 0
|
||||
? Math.round((dl.bytes_done / dl.bytes_total) * 100) : 0;
|
||||
let action = '';
|
||||
if (locked) {
|
||||
action = `<div class="text-xs text-gray-500">Unlocks at ${v.star_threshold} ★ — ${Math.max(0, v.star_threshold - state.stars_total)} to go</div>`;
|
||||
} else if (dl.status === 'running') {
|
||||
action = `<div class="career-bar-track mb-1" style="height:0.375rem"><div class="career-bar-fill" style="width:${pct}%"></div></div>
|
||||
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
|
||||
} else if (v.installed) {
|
||||
const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
|
||||
const main = active
|
||||
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
|
||||
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
|
||||
action = `<div class="flex items-center gap-2">
|
||||
${main}
|
||||
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
|
||||
</div>`;
|
||||
} else if (v.has_pack) {
|
||||
const err = dl.status === 'error'
|
||||
? `<div class="text-xs text-amber-400 mb-1">${esc(dl.error || 'Download failed')} — try again</div>` : '';
|
||||
action = `${err}<button data-career-download="${esc(v.id)}" class="career-btn career-btn-primary">Download venue pack</button>`;
|
||||
} else {
|
||||
action = '<div class="text-xs text-gray-500">Venue pack coming soon — plays with the standard stage for now</div>';
|
||||
}
|
||||
// Mirror pushCrowdManifest(): an override only counts while the pack
|
||||
// is installed — after a removal the badge must not claim a venue the
|
||||
// crowd layer can't use.
|
||||
const isActive = !locked && v.installed &&
|
||||
localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
|
||||
return `<div class="rounded-xl border ${locked ? 'border-gray-800 opacity-60' : 'border-gray-700'} bg-dark-700/40 p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-white">${esc(v.name)}${isActive ? ' <span class="text-cyan-400 text-xs">● playing here</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-400">${v.star_threshold} ★</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 flex-1">${esc(v.description)}</div>
|
||||
${action}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function starGlyphs(n) {
|
||||
let out = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
out += `<span class="${i < n ? 'on' : 'off'}">★</span>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderStars(state) {
|
||||
const list = $('career-star-list');
|
||||
const summary = $('career-star-summary');
|
||||
if (!list || !summary) return;
|
||||
const detail = state.star_detail || [];
|
||||
const tiers = [0, 0, 0, 0];
|
||||
for (const r of detail) tiers[r.stars]++;
|
||||
summary.textContent =
|
||||
`${tiers[3]}× 3★ · ${tiers[2]}× 2★ · ${tiers[1]}× 1★ · ${tiers[0]} unstarred`;
|
||||
if (!detail.length) {
|
||||
list.innerHTML = '<div class="text-xs text-gray-500">Play songs to start collecting stars — 60% accuracy earns the first one.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = detail.map((r) => {
|
||||
let hint = 'maxed';
|
||||
let close = '';
|
||||
if (r.next_star_at != null) {
|
||||
const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100;
|
||||
hint = `${gap.toFixed(0)}% to next ★`;
|
||||
if (gap <= 5) close = ' close';
|
||||
}
|
||||
return `<div class="career-star-row">
|
||||
<span class="stars">${starGlyphs(r.stars)}</span>
|
||||
<span class="song">${esc(r.title)}${r.artist ? ` <span class="artist">— ${esc(r.artist)}</span>` : ''}</span>
|
||||
<span class="hint${close}">best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
const host = $('career-venues');
|
||||
if (!host) return;
|
||||
$('career-stars-summary').textContent = `★ ${state.stars_total} total`;
|
||||
const next = state.venues.find((v) => !v.unlocked);
|
||||
const bar = $('career-progress-bar');
|
||||
const label = $('career-progress-label');
|
||||
if (next) {
|
||||
const prevThreshold = state.venues
|
||||
.filter((v) => v.unlocked)
|
||||
.reduce((m, v) => Math.max(m, v.star_threshold), 0);
|
||||
const span = Math.max(1, next.star_threshold - prevThreshold);
|
||||
const into = Math.max(0, state.stars_total - prevThreshold);
|
||||
bar.style.width = Math.min(100, Math.round((into / span) * 100)) + '%';
|
||||
label.textContent = `${state.stars_total} / ${next.star_threshold} ★ to unlock ${next.name}`;
|
||||
} else {
|
||||
bar.style.width = '100%';
|
||||
label.textContent = 'All venues unlocked — enjoy the arena.';
|
||||
}
|
||||
host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join('');
|
||||
renderStars(state);
|
||||
}
|
||||
|
||||
function schedulePoll(state) {
|
||||
clearTimeout(_pollTimer);
|
||||
if (state.venues.some((v) => (v.download || {}).status === 'running')) {
|
||||
_pollTimer = setTimeout(refresh, POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function announceUnlocks(state) {
|
||||
const unlocked = state.venues.filter((v) => v.unlocked).map((v) => v.id);
|
||||
if (_prevUnlockedIds) {
|
||||
for (const v of state.venues) {
|
||||
if (v.unlocked && !_prevUnlockedIds.includes(v.id)) {
|
||||
const sm = window.feedBack;
|
||||
if (sm && typeof sm.emit === 'function') {
|
||||
sm.emit('career:venue-unlocked', { id: v.id, name: v.name });
|
||||
}
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
window.fbNotify.show({
|
||||
big: true, icon: '🎤', accent: '#06B6D4',
|
||||
title: 'New venue unlocked!',
|
||||
message: `${v.name} — your crowd just got bigger.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_prevUnlockedIds = unlocked;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let state;
|
||||
try {
|
||||
state = await fetchState();
|
||||
} catch (_) {
|
||||
return; // server restarting; next trigger retries
|
||||
}
|
||||
_state = state;
|
||||
announceUnlocks(state);
|
||||
render(state);
|
||||
schedulePoll(state);
|
||||
pushCrowdManifest(state);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
const dlBtn = e.target.closest('[data-career-download]');
|
||||
const delBtn = e.target.closest('[data-career-delete]');
|
||||
const playBtn = e.target.closest('[data-career-play]');
|
||||
if (dlBtn) {
|
||||
fetch(`${API}/packs/${dlBtn.dataset.careerDownload}/download`, { method: 'POST' })
|
||||
.then(refresh);
|
||||
} else if (delBtn) {
|
||||
// Do NOT null _appliedManifestVenue here: pushCrowdManifest()
|
||||
// clears/replaces the crowd manifest precisely by seeing that the
|
||||
// applied venue is no longer among the installed ones.
|
||||
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
|
||||
.then(refresh);
|
||||
} else if (playBtn) {
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay);
|
||||
// Selecting a venue makes the Venue visualization the default;
|
||||
// remember what the user had so Leave venue can restore it.
|
||||
const cur = localStorage.getItem('vizSelection');
|
||||
if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur);
|
||||
localStorage.setItem('vizSelection', 'venue');
|
||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||
} catch (_) { /* ok */ }
|
||||
_appliedManifestVenue = null; // force manifest re-push
|
||||
refresh();
|
||||
} else if (e.target.closest('[data-career-unselect]')) {
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE);
|
||||
const prev = localStorage.getItem(PREV_VIZ_KEY);
|
||||
if (prev) {
|
||||
localStorage.setItem('vizSelection', prev);
|
||||
if (typeof window.setViz === 'function') window.setViz(prev);
|
||||
}
|
||||
} catch (_) { /* ok */ }
|
||||
// keep _appliedManifestVenue: pushCrowdManifest clears the crowd
|
||||
// manifest precisely by seeing it is still set with no venue left
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const screen = document.getElementById('plugin-career');
|
||||
if (screen) screen.addEventListener('click', onClick);
|
||||
const sm = window.feedBack;
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
// New song stats can add stars → thresholds may cross mid-session.
|
||||
sm.on('stats:recorded', () => refresh());
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="space-y-3 text-sm">
|
||||
<label class="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="text-gray-200 font-medium">Crowd sound reactions</span>
|
||||
<span class="block text-xs text-gray-500">Cheers when the crowd's mood rises, boos when it drops. Uses each venue's own recordings.</span>
|
||||
</span>
|
||||
<input type="checkbox" id="career-sfx-toggle" class="accent-cyan-500 w-4 h-4">
|
||||
</label>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var KEY = 'feedBack-venue-crowd-sfx';
|
||||
var box = document.getElementById('career-sfx-toggle');
|
||||
if (!box) return;
|
||||
try { box.checked = localStorage.getItem(KEY) === 'on'; } catch (e) { /* ok */ }
|
||||
box.addEventListener('change', function () {
|
||||
try { localStorage.setItem(KEY, box.checked ? 'on' : 'off'); } catch (e) { /* ok */ }
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"star_accuracy_thresholds": [
|
||||
0.6,
|
||||
0.75,
|
||||
0.85
|
||||
],
|
||||
"venues": [
|
||||
{
|
||||
"id": "bar",
|
||||
"name": "The Dive Bar",
|
||||
"description": "Sticky floors, a dozen regulars, and a PA that has seen better decades.",
|
||||
"star_threshold": 0,
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "club",
|
||||
"name": "Velvet Room",
|
||||
"description": "A proper club stage. People actually came to hear you.",
|
||||
"star_threshold": 50,
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "arena",
|
||||
"name": "Feedback Arena",
|
||||
"description": "Ten thousand seats. Try not to think about it.",
|
||||
"star_threshold": 150,
|
||||
"pack": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,8 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||
import appstate
|
||||
import builtin_content
|
||||
import demo_mode
|
||||
import scan
|
||||
# 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
|
||||
@@ -80,222 +82,18 @@ 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
|
||||
# Demo mode lives in lib/demo_mode.py now. The guard is a middleware, so it needs the app —
|
||||
# server.py owns it and hands it over rather than making lib/ reach for a global.
|
||||
demo_mode.install(app)
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -427,7 +225,12 @@ class TuningProviderRegistry:
|
||||
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)
|
||||
# `log`, not `logger` — there is no `logger` in this module. This handler
|
||||
# exists so ONE bad provider cannot break tunings for everyone; with the
|
||||
# wrong name it raised NameError from inside the except and did precisely
|
||||
# what it was written to prevent. See #899 and
|
||||
# tests/test_tuning_provider_isolation.py.
|
||||
log.exception("tuning provider %r raised during get_merged()", provider_id)
|
||||
return result
|
||||
|
||||
|
||||
@@ -519,8 +322,8 @@ def _stat_for_cache(f: Path) -> tuple[float, int]:
|
||||
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.
|
||||
# OSError bubble out of scan.background_scan(), killing the
|
||||
# scan thread while `scan.status()["running"]` stays true.
|
||||
stats = []
|
||||
for p in inner:
|
||||
try:
|
||||
@@ -533,8 +336,6 @@ def _stat_for_cache(f: Path) -> tuple[float, int]:
|
||||
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,
|
||||
@@ -605,45 +406,6 @@ def _get_startup_status():
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -699,167 +461,9 @@ appstate.configure(
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(_feedBack_server_root(), dlc)
|
||||
builtin_content.seed_builtin_starter_content(_feedBack_server_root(), 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:
|
||||
@@ -867,7 +471,7 @@ def _join_background_db_threads(timeout: float = 30.0) -> None:
|
||||
|
||||
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
|
||||
st = scan.scan_thread()
|
||||
if st is not None and st.is_alive():
|
||||
st.join(timeout)
|
||||
et = enrichment._enrich_thread
|
||||
@@ -875,50 +479,8 @@ def _join_background_db_threads(timeout: float = 30.0) -> None:
|
||||
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) ─────────────────────
|
||||
@@ -1142,7 +704,7 @@ async def startup_events():
|
||||
# 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_scan_status": lambda: dict(scan.status()),
|
||||
"get_art_cache_dir": lambda: ART_CACHE_DIR,
|
||||
"library_providers": library_providers,
|
||||
"register_library_provider": register_library_provider,
|
||||
@@ -1150,7 +712,7 @@ async def startup_events():
|
||||
"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,
|
||||
"register_demo_janitor_hook": demo_mode.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
|
||||
@@ -1408,18 +970,13 @@ async def startup_events():
|
||||
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()
|
||||
# NB the `or ... == "1" and not started` shape below is PRESERVED VERBATIM: `and` binds
|
||||
# tighter than `or`, so the re-entry guard is dead whenever the env var is truthy, and a
|
||||
# second startup leaks a janitor thread. That is issue #902 — not fixed here, because a
|
||||
# carve whose value is being provably behaviour-neutral is not the place to change
|
||||
# behaviour.
|
||||
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" and not demo_mode.janitor_started():
|
||||
demo_mode.start_janitor()
|
||||
|
||||
# Start background metadata scan
|
||||
startup_scan()
|
||||
@@ -1428,33 +985,20 @@ async def startup_events():
|
||||
@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
|
||||
global _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()
|
||||
if not demo_mode.stop_janitor(timeout=5):
|
||||
warnings.warn(
|
||||
"demo-janitor thread did not stop within 5 s; "
|
||||
"a registered hook may be blocking",
|
||||
RuntimeWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
|
||||
def startup_scan():
|
||||
"""Start background metadata scan and periodic rescan on server start."""
|
||||
_kick_scan()
|
||||
scan.kick_scan()
|
||||
# Periodic rescan every 5 minutes
|
||||
rescan_thread = threading.Thread(target=_periodic_rescan, daemon=True)
|
||||
rescan_thread.start()
|
||||
@@ -1464,10 +1008,10 @@ 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
|
||||
# scan.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()
|
||||
scan.kick_scan()
|
||||
time.sleep(300)
|
||||
|
||||
|
||||
@@ -1480,7 +1024,7 @@ app.include_router(version.router)
|
||||
|
||||
@app.get("/api/scan-status")
|
||||
def scan_status():
|
||||
return _scan_status
|
||||
return scan.status()
|
||||
|
||||
|
||||
# ── Enrichment routes → routers/enrichment.py (R3) ──────────────────────────
|
||||
@@ -1571,7 +1115,7 @@ async def startup_status_stream(request: Request):
|
||||
@app.post("/api/rescan")
|
||||
def trigger_rescan():
|
||||
"""Manually trigger a library rescan."""
|
||||
if not _kick_scan():
|
||||
if not scan.kick_scan():
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Rescan started"}
|
||||
|
||||
@@ -1579,7 +1123,7 @@ def trigger_rescan():
|
||||
@app.post("/api/rescan/full")
|
||||
def trigger_full_rescan():
|
||||
"""Clear cache and rescan everything."""
|
||||
if _scan_status["running"]:
|
||||
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()
|
||||
@@ -1589,7 +1133,7 @@ def trigger_full_rescan():
|
||||
# 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():
|
||||
if not scan.kick_scan():
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Full rescan started"}
|
||||
|
||||
@@ -1639,13 +1183,25 @@ def _invalidate_song_caches(cache_key: str) -> None:
|
||||
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.
|
||||
# Publish the scan/ingest seam. The scanner itself is lib/scan.py now; these are the
|
||||
# handles the routers reach it (and its neighbours) through.
|
||||
#
|
||||
# scan_status is a CALLABLE, not the dict. lib/scan.py REBINDS the status dict on every
|
||||
# stage transition rather than updating it in place, so a value published here would be a
|
||||
# snapshot frozen at whatever stage it happened to be captured — it would report "listing"
|
||||
# forever while the scan ran to completion.
|
||||
#
|
||||
# server_root is published for the same reason lib/builtin_content.py takes it as a
|
||||
# parameter: `Path(__file__).resolve().parent` is right HERE and silently wrong anywhere
|
||||
# under lib/ (it yields lib/, which holds no docs/ or data/), and it fails by finding
|
||||
# nothing rather than by raising. server.py is the only module that legitimately knows
|
||||
# where it lives, so it says so once, here.
|
||||
appstate.configure(
|
||||
kick_scan=_kick_scan,
|
||||
kick_scan=scan.kick_scan,
|
||||
invalidate_song_caches=_invalidate_song_caches,
|
||||
stat_for_cache=_stat_for_cache,
|
||||
scan_status=lambda: _scan_status,
|
||||
scan_status=scan.status,
|
||||
server_root=_feedBack_server_root(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -48,6 +48,7 @@
|
||||
// above. Screens are injected async by the plugin loader, so go()'s
|
||||
// plugin- guard applies.
|
||||
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
|
||||
{ key: 'career', screen: 'plugin-career', label: 'Career', group: null, icon: 'trophy' },
|
||||
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
|
||||
{ key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' },
|
||||
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
|
||||
@@ -60,6 +61,7 @@
|
||||
// that group. Each is gated on the plugin actually being installed.
|
||||
const PROMOTED_PLUGINS = [
|
||||
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
|
||||
{ navKey: 'career', pluginId: 'career', slotId: 'v3-nav-career', anchorAfter: 'feedbarcade' },
|
||||
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
|
||||
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
|
||||
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
|
||||
|
||||
@@ -136,3 +136,34 @@ def reset_plugin_state(monkeypatch):
|
||||
sys.modules.update(saved_modules)
|
||||
sys.modules.update(saved_bare)
|
||||
sys.path[:] = saved_path
|
||||
|
||||
|
||||
# ── Scanner isolation ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# lib/scan.py holds MODULE-LEVEL state (_scan_status, and the kick/runner bookkeeping),
|
||||
# and `scan` is NOT re-imported by the fixtures that re-import `server` — so unlike the
|
||||
# old server-globals arrangement, that state now outlives a test.
|
||||
#
|
||||
# It matters because of a deliberate asymmetry in the scanner: background_scan() never
|
||||
# sets `running` back to False. Ownership of that flag lives in _scan_runner, so that a
|
||||
# kick_scan() racing the terminal write cannot see a stale False and start a second runner.
|
||||
# Correct in production — but a test that calls background_scan() DIRECTLY skips the runner
|
||||
# entirely and therefore leaves the scanner marked "running" forever. Every later scan or
|
||||
# rescan then returns "already in progress" and quietly does nothing.
|
||||
#
|
||||
# The suite passed anyway, on ordering luck. Codex [P2] caught it. So: snapshot and restore.
|
||||
@pytest.fixture()
|
||||
def reset_scan_state():
|
||||
"""Restore lib/scan.py's module-level state around a test that drives it directly."""
|
||||
import scan
|
||||
|
||||
saved_status = scan._scan_status
|
||||
saved_thread = scan._scan_thread
|
||||
saved_pending = scan._scan_rescan_pending
|
||||
scan._scan_status = dict(scan._SCAN_STATUS_INIT)
|
||||
try:
|
||||
yield scan
|
||||
finally:
|
||||
scan._scan_status = saved_status
|
||||
scan._scan_thread = saved_thread
|
||||
scan._scan_rescan_pending = saved_pending
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career');
|
||||
const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js');
|
||||
|
||||
test('career plugin manifest is complete and bundled', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
||||
assert.equal(manifest.id, 'career');
|
||||
assert.equal(manifest.bundled, true);
|
||||
assert.equal(manifest.screen, 'screen.html');
|
||||
assert.equal(manifest.script, 'screen.js');
|
||||
assert.equal(manifest.routes, 'routes.py');
|
||||
for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) {
|
||||
assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test('venues.json defines the 3 ascending tiers with star thresholds', () => {
|
||||
const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8'));
|
||||
assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]);
|
||||
const venues = content.venues;
|
||||
assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']);
|
||||
assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked');
|
||||
for (let i = 1; i < venues.length; i++) {
|
||||
assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold,
|
||||
'thresholds must ascend');
|
||||
}
|
||||
});
|
||||
|
||||
test('shell promotes the career plugin into the sidebar', () => {
|
||||
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
||||
assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/);
|
||||
});
|
||||
|
||||
test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8');
|
||||
assert.match(src, /v3VenueCrowd/);
|
||||
assert.match(src, /setManifest\(manifest\)/);
|
||||
assert.match(src, /manifest\.base = /);
|
||||
assert.match(src, /feedBack-career-venue/);
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'career'))
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as career_routes
|
||||
|
||||
|
||||
class FakeMetaDb:
|
||||
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
|
||||
|
||||
def __init__(self):
|
||||
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE song_stats (
|
||||
filename TEXT, arrangement TEXT, best_accuracy REAL
|
||||
)"""
|
||||
)
|
||||
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
|
||||
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy))
|
||||
if in_library:
|
||||
self.conn.execute(
|
||||
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
|
||||
"(SELECT 1 FROM songs WHERE filename = ?)",
|
||||
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_career_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = career_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
# Module state outlives tests when the module stays imported — reset the
|
||||
# mutable bits so ordering can't leak downloads/content between tests.
|
||||
career_routes._state["downloads"] = {}
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meta_db():
|
||||
return FakeMetaDb()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, meta_db):
|
||||
app = FastAPI()
|
||||
career_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
|
||||
return TestClient(app)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""HTTP-level tests for the career plugin: stars, unlocks, packs."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import routes as career_routes
|
||||
|
||||
|
||||
def _install_fake_pack(venue_id, files=None):
|
||||
"""Drop a valid installed pack into the plugin's venues dir."""
|
||||
pack_dir = career_routes._venue_dir(venue_id)
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
loops = {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS}
|
||||
(pack_dir / "manifest.json").write_text(json.dumps(
|
||||
{"venue": venue_id, "version": 1, "loops": loops,
|
||||
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"}}))
|
||||
for name in list(loops.values()) + ["clap.mp4", "cheer.mp4"]:
|
||||
(pack_dir / name).write_bytes((files or {}).get(name, b"\x00video"))
|
||||
|
||||
|
||||
def test_stars_from_best_accuracy_across_arrangements(client, meta_db):
|
||||
# Thresholds 0.6/0.75/0.85 → 1/2/3 stars; best arrangement wins.
|
||||
meta_db.add("a.feedpak", "guitar", 0.5) # 0 stars
|
||||
meta_db.add("b.feedpak", "guitar", 0.62) # 1 star
|
||||
meta_db.add("c.feedpak", "guitar", 0.70)
|
||||
meta_db.add("c.feedpak", "bass", 0.80) # 2 stars (max across arrangements)
|
||||
meta_db.add("d.feedpak", "guitar", 0.99) # 3 stars
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert state["stars_total"] == 6
|
||||
assert state["stars_per_song"] == {"b.feedpak": 1, "c.feedpak": 2, "d.feedpak": 3}
|
||||
|
||||
|
||||
def test_unlock_flags_follow_thresholds(client, meta_db):
|
||||
# 6 stars: bar (0) unlocked, club (50) and arena (150) locked.
|
||||
for i in range(2):
|
||||
meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
by_id = {v["id"]: v for v in state["venues"]}
|
||||
assert by_id["bar"]["unlocked"] is True
|
||||
assert by_id["club"]["unlocked"] is False
|
||||
assert by_id["arena"]["unlocked"] is False
|
||||
|
||||
|
||||
def test_orphaned_stats_do_not_count(client, meta_db):
|
||||
# A song removed from the library (stats row survives the scan) must not
|
||||
# keep contributing stars.
|
||||
meta_db.add("gone.feedpak", "guitar", 0.99, in_library=False)
|
||||
meta_db.add("here.feedpak", "guitar", 0.99)
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert state["stars_total"] == 3
|
||||
assert "gone.feedpak" not in state["stars_per_song"]
|
||||
|
||||
|
||||
def test_star_detail_rows_sorted_by_next_star_gap(client, meta_db):
|
||||
meta_db.add("far.feedpak", "guitar", 0.61) # 1★, 14% from next
|
||||
meta_db.add("close.feedpak", "guitar", 0.84) # 2★, 1% from next
|
||||
meta_db.add("maxed.feedpak", "guitar", 0.99) # 3★, maxed
|
||||
detail = client.get("/api/plugins/career/state").json()["star_detail"]
|
||||
assert [r["filename"] for r in detail] == \
|
||||
["close.feedpak", "far.feedpak", "maxed.feedpak"]
|
||||
close = detail[0]
|
||||
assert close["stars"] == 2 and close["next_star_at"] == 0.85
|
||||
assert detail[2]["next_star_at"] is None
|
||||
|
||||
|
||||
def test_no_stats_still_serves_state(client):
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert state["stars_total"] == 0
|
||||
assert state["venues"][0]["unlocked"] is True # bar is always open
|
||||
|
||||
|
||||
def test_download_unknown_venue_404s(client):
|
||||
assert client.post("/api/plugins/career/packs/nope/download").status_code == 404
|
||||
assert client.post("/api/plugins/career/packs/../etc/download").status_code == 404
|
||||
|
||||
|
||||
def test_download_without_published_pack_404s(client):
|
||||
# venues.json ships pack: null until packs are released.
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 404
|
||||
|
||||
|
||||
def test_download_locked_venue_403s(client, monkeypatch):
|
||||
club = career_routes._venue("club")
|
||||
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
||||
|
||||
|
||||
def test_pack_file_serving_and_traversal_guard(client):
|
||||
_install_fake_pack("bar")
|
||||
ok = client.get("/api/plugins/career/venues/bar/manifest.json")
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4"
|
||||
video = client.get("/api/plugins/career/venues/bar/bored.mp4")
|
||||
assert video.status_code == 200
|
||||
assert video.headers["content-type"].startswith("video/mp4")
|
||||
assert video.headers["x-content-type-options"] == "nosniff"
|
||||
# Traversal / junk shapes never resolve.
|
||||
for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"):
|
||||
assert client.get(f"/api/plugins/career/venues/bar/{bad}").status_code == 404
|
||||
assert client.get("/api/plugins/career/venues/../bar/manifest.json").status_code == 404
|
||||
|
||||
|
||||
def test_state_reports_installed_and_delete_removes(client):
|
||||
_install_fake_pack("bar")
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True
|
||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 200
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is False
|
||||
|
||||
|
||||
def test_download_worker_end_to_end(client, tmp_path):
|
||||
# Build a real pack zip, serve it via file://, verify the full worker path:
|
||||
# stream → sha256 → extract (flat names only) → validate → swap in.
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
names = [f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS] + ["cheer.mp4"]
|
||||
for name in names:
|
||||
(src / name).write_bytes(b"fake-video-" + name.encode())
|
||||
(src / "manifest.json").write_text(json.dumps({
|
||||
"venue": "bar", "version": 1,
|
||||
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
|
||||
"stingers": {"cheer": "cheer.mp4"},
|
||||
}))
|
||||
zip_path = tmp_path / "bar-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for p in src.iterdir():
|
||||
zf.write(p, p.name)
|
||||
sha = hashlib.sha256(zip_path.read_bytes()).hexdigest()
|
||||
|
||||
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||
career_routes._download_pack(
|
||||
"bar", {"url": zip_path.as_uri(), "sha256": sha}, progress)
|
||||
assert progress["status"] == "done", progress["error"]
|
||||
assert career_routes._installed("bar")
|
||||
assert progress["bytes_done"] == zip_path.stat().st_size
|
||||
|
||||
# Corrupt hash → error status, nothing installed over the good pack.
|
||||
bad = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||
career_routes._download_pack("bar", {"url": zip_path.as_uri(), "sha256": "0" * 64}, bad)
|
||||
assert bad["status"] == "error"
|
||||
assert "sha256" in bad["error"]
|
||||
|
||||
|
||||
def test_double_download_409s(client, monkeypatch):
|
||||
bar = career_routes._venue("bar")
|
||||
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
# Pretend one is already running.
|
||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
|
||||
+24
-23
@@ -16,6 +16,7 @@ Covers:
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import demo_mode
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -50,14 +51,14 @@ def _cleanup(server, client):
|
||||
client.close()
|
||||
# Stop the demo-mode janitor thread (if started) so daemon threads don't
|
||||
# accumulate across tests.
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -221,15 +222,15 @@ def test_register_demo_janitor_hook_is_callable(tmp_path, monkeypatch):
|
||||
server, client = _make_client(tmp_path, monkeypatch, demo=True)
|
||||
try:
|
||||
called = []
|
||||
server.register_demo_janitor_hook(lambda: called.append(1))
|
||||
demo_mode.register_demo_janitor_hook(lambda: called.append(1))
|
||||
# Manually invoke the registered hooks (simulating a janitor sweep).
|
||||
for hook in list(server._DEMO_JANITOR_HOOKS):
|
||||
for hook in list(demo_mode._DEMO_JANITOR_HOOKS):
|
||||
hook()
|
||||
assert 1 in called
|
||||
finally:
|
||||
# Clean up our test hook so it doesn't leak into other tests.
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
_cleanup(server, client)
|
||||
|
||||
|
||||
@@ -238,7 +239,7 @@ def test_register_demo_janitor_hook_rejects_non_callable(tmp_path, monkeypatch):
|
||||
server, client = _make_client(tmp_path, monkeypatch, demo=True)
|
||||
try:
|
||||
with pytest.raises(TypeError):
|
||||
server.register_demo_janitor_hook("not a function")
|
||||
demo_mode.register_demo_janitor_hook("not a function")
|
||||
finally:
|
||||
_cleanup(server, client)
|
||||
|
||||
@@ -251,7 +252,7 @@ def test_register_demo_janitor_hook_rejects_async_callable(tmp_path, monkeypatch
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError, match="async"):
|
||||
server.register_demo_janitor_hook(_async_hook)
|
||||
demo_mode.register_demo_janitor_hook(_async_hook)
|
||||
finally:
|
||||
_cleanup(server, client)
|
||||
|
||||
@@ -264,7 +265,7 @@ def test_register_demo_janitor_hook_rejects_non_zero_arg_callable(tmp_path, monk
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError, match="zero-argument"):
|
||||
server.register_demo_janitor_hook(_needs_arg)
|
||||
demo_mode.register_demo_janitor_hook(_needs_arg)
|
||||
finally:
|
||||
_cleanup(server, client)
|
||||
|
||||
@@ -276,10 +277,10 @@ def test_register_demo_janitor_hook_accepts_default_arg_callable(tmp_path, monke
|
||||
def _optional_arg(x=None):
|
||||
pass
|
||||
|
||||
server.register_demo_janitor_hook(_optional_arg)
|
||||
demo_mode.register_demo_janitor_hook(_optional_arg)
|
||||
finally:
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
_cleanup(server, client)
|
||||
|
||||
|
||||
@@ -308,21 +309,21 @@ def test_register_demo_janitor_hook_in_plugin_context(tmp_path, monkeypatch):
|
||||
assert "register_demo_janitor_hook" in captured, (
|
||||
"register_demo_janitor_hook was not passed in the plugin context"
|
||||
)
|
||||
assert captured["register_demo_janitor_hook"] is server.register_demo_janitor_hook
|
||||
assert captured["register_demo_janitor_hook"] is demo_mode.register_demo_janitor_hook
|
||||
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
# Clean up janitor state so it doesn't bleed into other tests.
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def test_is_sloppak_rejects_other_suffixes(name):
|
||||
# ── 2. _background_scan discovery glob (DLC scan) ────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def scan_server(tmp_path, monkeypatch, isolate_logging):
|
||||
def scan_server(tmp_path, monkeypatch, isolate_logging, reset_scan_state):
|
||||
"""Fresh server import with the background scan forced in-process.
|
||||
|
||||
Mirrors tests/test_settings_api.py::scan_module — the production scan uses
|
||||
@@ -94,8 +94,13 @@ def scan_server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.delenv("DLC_DIR", raising=False)
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
# The scanner is lib/scan.py now (R3b). Patch it THERE — `mod` (server) re-imports
|
||||
# per-test, but `scan` stays cached in sys.modules, so this is the same module object
|
||||
# server calls into. That it still works is the point of the late-bound appstate
|
||||
# reads: scan picks up the fresh CONFIG_DIR without being re-imported itself.
|
||||
import scan as scan_mod
|
||||
monkeypatch.setattr(
|
||||
mod, "_make_scan_executor",
|
||||
scan_mod, "_make_scan_executor",
|
||||
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
|
||||
)
|
||||
yield mod
|
||||
@@ -125,7 +130,7 @@ def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
|
||||
return {"title": f.name, "artist": "", "album": ""}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
scan_server._background_scan()
|
||||
importlib.import_module("scan").background_scan()
|
||||
|
||||
assert "new.feedpak" in seen
|
||||
assert "legacy.sloppak" in seen
|
||||
|
||||
@@ -429,11 +429,11 @@ def test_get_dlc_dir_ignores_nonexistent_config_dir(tmp_path, server_module):
|
||||
# ── library scan fixtures ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def scan_module(tmp_path, monkeypatch, isolate_logging):
|
||||
def scan_module(tmp_path, monkeypatch, isolate_logging, reset_scan_state):
|
||||
"""Import server with CONFIG_DIR and DLC_DIR isolated in tmp_path.
|
||||
|
||||
The background scan uses a `spawn` ProcessPoolExecutor in production
|
||||
(see server._make_scan_executor), whose workers run in fresh
|
||||
(see scan._make_scan_executor), whose workers run in fresh
|
||||
interpreters that an in-process mock.patch() can't reach. Override it
|
||||
with an in-process ThreadPoolExecutor so these tests can mock metadata
|
||||
extraction (on scan_worker, where the worker resolves it) and observe
|
||||
@@ -444,8 +444,13 @@ def scan_module(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.delenv("DLC_DIR", raising=False)
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
# The scanner is lib/scan.py now (R3b). Patch it THERE — `mod` (server) re-imports
|
||||
# per-test, but `scan` stays cached in sys.modules, so this is the same module object
|
||||
# server calls into. That it still works is the point of the late-bound appstate
|
||||
# reads: scan picks up the fresh CONFIG_DIR without being re-imported itself.
|
||||
import scan as scan_mod
|
||||
monkeypatch.setattr(
|
||||
mod, "_make_scan_executor",
|
||||
scan_mod, "_make_scan_executor",
|
||||
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
|
||||
)
|
||||
yield mod
|
||||
@@ -485,11 +490,11 @@ def test_is_first_scan_true_when_all_songs_unscanned(tmp_path, scan_module):
|
||||
def mock_extract(f, dlc):
|
||||
# Capture the scan status on the first call (during the scanning phase)
|
||||
if not captured_status:
|
||||
captured_status.update(scan_module._scan_status)
|
||||
captured_status.update(importlib.import_module("scan").status())
|
||||
return {"title": f.name, "artist": "", "album": ""}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
scan_module._background_scan()
|
||||
importlib.import_module("scan").background_scan()
|
||||
|
||||
assert captured_status.get("is_first_scan") is True
|
||||
|
||||
@@ -514,11 +519,11 @@ def test_is_first_scan_false_when_some_songs_cached(tmp_path, scan_module):
|
||||
|
||||
def mock_extract(f, dlc):
|
||||
if not captured_status:
|
||||
captured_status.update(scan_module._scan_status)
|
||||
captured_status.update(importlib.import_module("scan").status())
|
||||
return {"title": f.name, "artist": "", "album": ""}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
scan_module._background_scan()
|
||||
importlib.import_module("scan").background_scan()
|
||||
|
||||
assert captured_status.get("is_first_scan") is False
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import time
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import demo_mode
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -120,12 +121,12 @@ def startup_harness(tmp_path, monkeypatch, isolate_logging):
|
||||
|
||||
yield server, phases
|
||||
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -686,12 +687,12 @@ def test_startup_status_e2e_real_plugin_loader(tmp_path, monkeypatch, isolate_lo
|
||||
assert sentinel.status_code == 200
|
||||
assert sentinel.json() == {"ok": True}
|
||||
finally:
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -777,12 +778,12 @@ def test_startup_status_endpoint_background_thread_path(tmp_path, monkeypatch, i
|
||||
# actually executed the sentinel — proves the main-loop handoff path ran.
|
||||
assert _route_setup_called, "route_setup_fn was never called; call_soon_threadsafe path was not exercised"
|
||||
finally:
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -826,12 +827,12 @@ def test_startup_status_endpoint_background_thread_failure(tmp_path, monkeypatch
|
||||
assert data["phase"] == "error"
|
||||
assert _BG_ERROR in data["error"]
|
||||
finally:
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""A raising tuning provider must not take down get_merged() for everyone. (#899)
|
||||
|
||||
`TuningProviderRegistry.get_merged()` wraps each provider in a try/except precisely so one
|
||||
misbehaving plugin cannot break tunings for the rest. The handler called `logger.exception`
|
||||
— and there is no `logger` in server.py; the module logger is `log`. So the handler MEANT
|
||||
to swallow-and-report instead raised NameError, which propagated out of get_merged().
|
||||
|
||||
The net effect was the exact opposite of the handler's purpose: one bad provider took the
|
||||
whole merged-tunings call down, and the traceback named the wrong problem.
|
||||
|
||||
Nothing exercised the failure path, which is why it survived. This is that path.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def registry(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod.TuningProviderRegistry()
|
||||
|
||||
|
||||
def test_a_raising_provider_does_not_break_the_others(registry, caplog):
|
||||
"""The whole point of the try/except. Before the fix this raised NameError."""
|
||||
def boom():
|
||||
raise RuntimeError("provider exploded")
|
||||
|
||||
def good():
|
||||
return {"guitar": {"My Tuning": [82.41, 110.0, 146.83, 196.0, 246.94, 329.63]}}
|
||||
|
||||
registry.register("bad-plugin", boom)
|
||||
registry.register("good-plugin", good)
|
||||
|
||||
merged = registry.get_merged() # must NOT raise
|
||||
|
||||
assert "My Tuning" in merged["guitar"], (
|
||||
"the healthy provider's tuning is missing — one raising provider took down the "
|
||||
"merged result for everyone"
|
||||
)
|
||||
# and the default tunings survive
|
||||
assert merged["guitar"], "default tunings were lost"
|
||||
|
||||
|
||||
def test_the_failure_is_actually_logged(registry, caplog):
|
||||
"""Swallowing is only acceptable if it is reported. A NameError in the handler meant
|
||||
nothing was ever logged — the failure was both fatal AND silent about its real cause."""
|
||||
def boom():
|
||||
raise RuntimeError("provider exploded")
|
||||
|
||||
registry.register("bad-plugin", boom)
|
||||
|
||||
# The feedBack logger sets propagate=False, so pytest's root-logger capture sees
|
||||
# NOTHING from it. Attach caplog's handler directly. (test_plugins.py has a
|
||||
# capture_logger() context manager for this, but it is not importable from here:
|
||||
# pyproject pins pythonpath to [".", "lib"], so `tests` is not a package.)
|
||||
lg = logging.getLogger("feedBack")
|
||||
lg.addHandler(caplog.handler)
|
||||
lg.setLevel(logging.ERROR)
|
||||
try:
|
||||
registry.get_merged()
|
||||
finally:
|
||||
lg.removeHandler(caplog.handler)
|
||||
|
||||
assert any("bad-plugin" in r.getMessage() for r in caplog.records), (
|
||||
"the raising provider was never named in the logs"
|
||||
)
|
||||
Reference in New Issue
Block a user