diff --git a/CHANGELOG.md b/CHANGELOG.md index b738117..cce4232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. -- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. +- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a `fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 97af0ab..e42d25c 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(4,478 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and seventeen `routers/` modules (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) · +(3,692 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and eighteen `routers/` modules (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/lib/appstate.py b/lib/appstate.py index 06001b2..ee1a649 100644 --- a/lib/appstate.py +++ b/lib/appstate.py @@ -102,6 +102,14 @@ art_safe_name = None # The canonical settings-defaults builder — stays in server.py (shared with the # scan/artist-links code) but the settings router calls it through the seam. default_settings = None +# Scan/ingest seam for the song routes (routers/song.py). kick_scan/ +# invalidate_song_caches/stat_for_cache stay in server.py (scan lifecycle owns +# them); scan_status is a GETTER (the underlying dict is reassigned, so a value +# would go stale) — call appstate.scan_status() to read the live status. +kick_scan = None +invalidate_song_caches = None +stat_for_cache = None +scan_status = None _SLOTS = frozenset({ "meta_db", "audio_effect_mappings", "tuning_providers", @@ -111,6 +119,7 @@ _SLOTS = frozenset({ "running_version", "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", }) diff --git a/lib/enrichment.py b/lib/enrichment.py index a6431b4..fc78691 100644 --- a/lib/enrichment.py +++ b/lib/enrichment.py @@ -233,6 +233,10 @@ def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]: _ACOUSTID_MAX_UPLOAD_BYTES = 256 * 1024 * 1024 # 256 MB — an uncompressed master +# Slack for the multipart envelope on the Content-Length pre-parse guard, shared +# by the AcoustID-identify route (server.py) and the song-upload route +# (routers/song.py). The real per-file cap is the streaming check downstream. +_MULTIPART_OVERHEAD_SLACK = 1024 * 1024 # 1 MiB def _fpcalc_bin() -> str | None: diff --git a/lib/routers/song.py b/lib/routers/song.py new file mode 100644 index 0000000..72a1d06 --- /dev/null +++ b/lib/routers/song.py @@ -0,0 +1,867 @@ +"""Song routes: upload / delete / metadata (user-meta, overrides, catalog meta +write-back), gap-fill proposals, and the per-song info payload. + +Extracted verbatim from server.py (R3) except @app->@router and the seam reads: +meta_db->appstate.meta_db, and the scan/ingest helpers that stay in server.py +(the scan lifecycle owns them) -> appstate.: kick_scan, +invalidate_song_caches, stat_for_cache, scan_status() (a getter — the underlying +dict is reassigned), plus art_override_paths. The gap-fill MBID/ISRC regexes live +in lib/enrichment.py and are reached as enrichment.X. +""" + +import os +import shutil +import tempfile +import threading +from pathlib import Path + +from fastapi import APIRouter, Request, UploadFile +from fastapi.responses import JSONResponse +from starlette.concurrency import run_in_threadpool + +import appstate +import enrichment +import loosefolder as loosefolder_mod +import sloppak as sloppak_mod +from dlc_paths import _get_dlc_dir, _resolve_dlc_path +from scan_worker import _extract_meta_for_file + +import logging +log = logging.getLogger("feedBack.server") +router = APIRouter() + +_ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS) + +_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems + + +# Per-request batch cap. Lets a user drop a whole album of sloppaks at once +# without giving a hostile client a 1000-file DoS surface via Starlette's +# default max_files=1000. The pre-parse Content-Length guard is sized as +# _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + slack. +_MAX_UPLOAD_FILES = 50 + + +# Serializes the mutating step of upload (os.replace into DLC_DIR) with +# delete_song so the two endpoints can't interleave on the same path — +# e.g. an upload finishing right after a concurrent delete shouldn't +# resurrect a song the user just removed, and a delete arriving mid- +# overwrite shouldn't strand a half-written file. threading.Lock (not +# asyncio.Lock) because delete_song is sync (runs in the threadpool); +# upload acquires it inside ``run_in_threadpool`` for the same reason. +_song_io_lock = threading.Lock() + + +def _commit_uploaded_song(tmp_path: Path, dest: Path, overwrite: bool, base: str): + """Atomically move a validated temp upload into ``dest`` under ``_song_io_lock``. + + Returns ``None`` on success or an error result dict matching the upload + endpoint's contract. Holds the lock across the directory re-check and + the final ``os.replace`` so a concurrent delete or upload can't slip + between them. Always cleans up the temp file on the error paths. + """ + with _song_io_lock: + if dest.exists(): + if not overwrite: + # Lost the race against a concurrent upload of the same name. + try: + tmp_path.unlink() + except OSError: + pass + return {"status": "exists", "filename": base, + "error": "A file with this name already exists"} + # Re-check directory state under the lock — the pre-check + # may have raced an unrelated mkdir, and a sloppak directory + # has to be removed before os.replace() can write over it. + if dest.is_dir(): + if not sloppak_mod.is_sloppak(dest): + try: + tmp_path.unlink() + except OSError: + pass + return {"status": "exists", "filename": base, + "error": "A directory with this name exists and is not " + "a sloppak — refusing to overwrite"} + shutil.rmtree(str(dest)) + os.replace(str(tmp_path), str(dest)) + return None + + +@router.post("/api/songs/upload") +async def upload_song(request: Request): + """Upload one or more .sloppak files into the configured DLC folder. + + Multipart body with one or more ``file`` fields (up to ``_MAX_UPLOAD_FILES`` + per request). Query string: + ``overwrite=1`` — replace existing files with the same name. + + Response shape (always HTTP 200 once we've gotten past request-level guards + like DLC-not-configured / payload-too-large): + ``{"results": [{"filename": "...", "status": "ok" | "exists" | "error", + "error"?: "...", "size"?: N, "format"?: "sloppak"}, ...]}`` + Per-file conflicts surface as ``status: "exists"`` so a batch upload can + surface ALL conflicts at once instead of bailing on the first one. The + client re-POSTs just the conflicting files with ``overwrite=1`` if the + user opts in. + + The DLC directory is resolved via ``_get_dlc_dir()`` which honours the + ``DLC_DIR`` env var first and falls back to ``dlc_dir`` in + ``config.json`` — so uploads land in whichever folder the rest of the + app already considers the library root, regardless of which mechanism + configured it. + """ + dlc = _get_dlc_dir() + if dlc is None: + return JSONResponse( + {"error": "DLC folder is not configured. Set DLC_DIR or configure it in Settings."}, + status_code=503, + ) + if not os.access(str(dlc), os.W_OK): + return JSONResponse( + {"error": f"DLC folder {dlc} is not writable by the server process."}, + status_code=500, + ) + + # Pre-parse Content-Length guard — fail fast before reading any body. + # Multipart Content-Length is file bytes + boundary + per-part headers, so + # we can't use _MAX_UPLOAD_BYTES as an exact cap here (a file right at the + # advertised max would be rejected before _save_uploaded_song() can apply + # the real per-file byte cap). For batch uploads we allow up to + # _MAX_UPLOAD_FILES files at _MAX_UPLOAD_BYTES each; the parser still + # enforces per-part size via max_part_size and per-batch count via + # max_files. The streaming check inside _save_uploaded_song() is the + # authoritative per-file size cap. + max_total = _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK + cl = request.headers.get("content-length") + if cl is not None: + try: + cl_int = int(cl) + except ValueError: + return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) + if cl_int < 0: + return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) + if cl_int > max_total: + return JSONResponse( + {"error": f"Batch upload exceeds {_MAX_UPLOAD_FILES} files × " + f"{_MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit"}, + status_code=413, + ) + + overwrite = request.query_params.get("overwrite") == "1" + # Tighten the parser to the handler's contract: up to _MAX_UPLOAD_FILES + # file parts, no text parts (overwrite comes from query params). + # Starlette's defaults of max_files=1000 / max_fields=1000 would + # otherwise let a client force the parser to spool far more parts than + # the endpoint is willing to process. + form = await request.form( + max_files=_MAX_UPLOAD_FILES, + max_fields=0, + max_part_size=_MAX_UPLOAD_BYTES, + ) + try: + from starlette.datastructures import UploadFile as _StarletteUploadFile + # form.getlist("file") returns all parts named "file" in submission + # order. Filter to file parts only — Starlette would yield strings + # for text parts, but we've capped max_fields=0 so any non-file part + # is already a parser error before reaching here. + uploads = [u for u in form.getlist("file") if isinstance(u, _StarletteUploadFile)] + if not uploads: + return JSONResponse( + {"error": "Expected one or more files in multipart field 'file'"}, + status_code=400, + ) + + results = [] + any_saved = False + for upload in uploads: + try: + result = await _save_uploaded_song(upload, dlc, overwrite) + results.append(result) + if result.get("status") == "ok": + any_saved = True + except Exception as e: + # Per-file failure must not abort the batch — record and + # continue so the client gets a complete report. + log.exception("upload failed for %r", getattr(upload, "filename", "?")) + results.append({ + "filename": Path(getattr(upload, "filename", "") or "").name or "?", + "status": "error", + "error": f"Upload failed: {e}", + }) + finally: + try: + await upload.close() + except Exception: + log.debug("failed to close upload file handle", exc_info=True) + + if any_saved: + appstate.kick_scan() + return {"results": results} + finally: + try: + await form.close() + except Exception: + log.debug("failed to close form", exc_info=True) + + +async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) -> dict: + """Save one upload into ``dlc``. Returns a per-file result dict (never + a JSONResponse) so batch uploads can aggregate. + + Shape: + ok: ``{"status": "ok", "filename": base, "size": N, "format": "sloppak"}`` + exists: ``{"status": "exists", "filename": base, "error": "..."}`` + error: ``{"status": "error", "filename": base, "error": "..."}`` + """ + # Strip any path components a client may have included in the filename — + # only the basename lands in the DLC root. Path traversal would otherwise + # let a crafted upload escape the library directory. + raw_name = upload.filename or "" + base = Path(raw_name).name + if not base or base in (".", "..") or "/" in base or "\\" in base: + return {"status": "error", "filename": raw_name or "?", "error": "Invalid filename"} + suffix = Path(base).suffix.lower() + if suffix not in _ALLOWED_SONG_EXTS: + return {"status": "error", "filename": base, + "error": "Only .feedpak files are accepted"} + + dest = dlc / base + if dest.exists(): + if not overwrite: + return {"status": "exists", "filename": base, + "error": "A file with this name already exists"} + # overwrite=1 must handle directory-form sloppaks (the scanner and + # delete path both treat them as song entries). os.replace() can't + # clobber a non-empty directory, so without the rmtree below the + # whole upload would write to a temp file and then surface a late + # 500 at the os.replace() call. Refuse other directories so an + # unrelated folder isn't blown away by a same-named upload. + if dest.is_dir() and not sloppak_mod.is_sloppak(dest): + return {"status": "exists", "filename": base, + "error": "A directory with this name exists and is not a sloppak — " + "refusing to overwrite"} + + # Temp file in the DLC dir itself so os.replace is atomic (same filesystem). + # Dot-prefix keeps it out of the rglob("*.sloppak") scan glob. + fd, tmp_name = await run_in_threadpool( + tempfile.mkstemp, dir=str(dlc), prefix=".upload-", suffix=".part" + ) + tmp_path = Path(tmp_name) + bytes_read = 0 + head = b"" + error_result: dict | None = None + try: + try: + tmpf = await run_in_threadpool(os.fdopen, fd, "wb") + except BaseException: + try: + await run_in_threadpool(os.close, fd) + except OSError: + pass + raise + try: + while True: + chunk = await upload.read(1024 * 1024) + if not chunk: + break + bytes_read += len(chunk) + if bytes_read > _MAX_UPLOAD_BYTES: + error_result = { + "status": "error", "filename": base, + "error": f"Upload exceeds {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB cap", + } + break + if len(head) < 4: + head += chunk[: 4 - len(head)] + await run_in_threadpool(tmpf.write, chunk) + finally: + await run_in_threadpool(tmpf.close) + + if error_result is None: + if bytes_read == 0: + error_result = {"status": "error", "filename": base, + "error": "Empty upload — file is 0 bytes"} + elif suffix in _ALLOWED_SONG_EXTS: + if head[:2] != b"PK": + error_result = {"status": "error", "filename": base, + "error": "Not a valid feedpak file (expected zip archive)"} + else: + # ZIP magic alone admits any renamed zip — verify the sloppak + # loader can actually parse a manifest.yaml inside. Without + # this, /api/songs/upload returns "ok" for files the rest of + # the backend would refuse to scan or load. + try: + await run_in_threadpool(sloppak_mod.load_manifest, tmp_path) + except Exception as e: + error_result = {"status": "error", "filename": base, + "error": f"Not a valid sloppak file: {e}"} + + if error_result is not None: + try: + await run_in_threadpool(tmp_path.unlink) + except OSError: + pass + return error_result + + # Single sync helper so the lock is held for the whole commit — + # ``async with _upload_lock`` would have released between every + # ``run_in_threadpool`` and let a concurrent delete or upload slip + # in between the dir check and the final ``os.replace``. + commit_result = await run_in_threadpool( + _commit_uploaded_song, tmp_path, dest, overwrite, base + ) + if commit_result is not None: + return commit_result + except BaseException: + try: + await run_in_threadpool(tmp_path.unlink) + except OSError: + pass + raise + + # Even on a fresh (non-overwrite) upload, evict any stale entries left + # over from a previous delete+re-upload of the same name. + await run_in_threadpool(appstate.invalidate_song_caches, base) + + log.info("Uploaded %s (%d bytes) to %s", base, bytes_read, dlc) + return {"status": "ok", "filename": base, "size": bytes_read, + "format": suffix.lstrip(".")} + + +@router.delete("/api/song/{filename:path}") +def delete_song(filename: str): + """Remove a song from the DLC folder and clear its cache entries. + + Works for both formats: ``.sloppak`` files OR directories, and + loose-folder songs (the directory containing the chart). The path is + resolved through ``_resolve_dlc_path`` so URL-encoded ``..`` segments + cannot escape the library root. + """ + dlc = _get_dlc_dir() + if dlc is None: + return JSONResponse({"error": "DLC folder not configured"}, status_code=503) + resolved = _resolve_dlc_path(dlc, filename) + if resolved is None: + return JSONResponse({"error": "forbidden"}, status_code=403) + if not resolved.exists(): + return JSONResponse({"error": "File not found"}, status_code=404) + if resolved == dlc.resolve(): + return JSONResponse({"error": "Refusing to delete the DLC root"}, status_code=400) + + # Only delete actual song entries. Without this, DELETE /api/song/ArtistName + # would recursively wipe a whole artist subfolder — far broader than the + # UI's per-song contract. Sloppak detection wins over loose because a + # sloppak dir can also contain WEM/XML (matches the scanner's precedence). + is_sloppak = sloppak_mod.is_sloppak(resolved) + is_loose = ( + resolved.is_dir() + and not is_sloppak + and loosefolder_mod.is_loose_song(resolved) + ) + if not (is_sloppak or is_loose): + return JSONResponse( + {"error": "Not a song entry — only sloppaks " + "or loose-folder songs can be deleted"}, + status_code=400, + ) + + # Hold ``_song_io_lock`` across the filesystem removal AND the DB/cache + # eviction. Without it, an upload of the same filename could ``os.replace`` + # a new file into place between our removal and DB delete, leaving the + # new generation stranded with no library row; or the reverse, where + # delete runs between an upload's directory check and its replace and + # the upload then resurrects the song we just removed. + with _song_io_lock: + try: + if resolved.is_dir(): + shutil.rmtree(resolved) + else: + resolved.unlink() + except OSError as e: + log.error("Failed to delete %s: %s", resolved, e) + return JSONResponse({"error": f"Delete failed: {e}"}, status_code=500) + + # Canonicalise the cache key the same way update_song_meta does so we + # hit the row the scanner indexed under. + try: + cache_key = resolved.relative_to(dlc.resolve()).as_posix() + except ValueError: + cache_key = filename + with appstate.meta_db._lock: + appstate.meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", (cache_key,)) + appstate.meta_db.conn.execute("DELETE FROM favorites WHERE filename = ?", (cache_key,)) + appstate.meta_db.conn.execute("DELETE FROM loops WHERE filename = ?", (cache_key,)) + # Purge the v3 filename-keyed state too, so the deleted song stops + # surfacing in stats / recent / continue / playlists immediately. + appstate.meta_db.conn.execute("DELETE FROM song_stats WHERE filename = ?", (cache_key,)) + appstate.meta_db.conn.execute("DELETE FROM playlist_songs WHERE filename = ?", (cache_key,)) + # Personal difficulty / notes / tags for this song (we hold the + # lock, so purge is lock-free). + appstate.meta_db.purge_song_user_data(cache_key) + # Multi-chart grouping (P5a): drop this chart's split + read-model rows, + # and any preferred-chart pointer that named it (the work re-auto-picks). + # work_key-keyed prefs for OTHER charts survive. Mark the read-model + # dirty so the affected work regroups on the next grouped query. + appstate.meta_db.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (cache_key,)) + appstate.meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,)) + appstate.meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,)) + appstate.meta_db._work_display_dirty = True + # Enrichment is never purged on rescan (delete_missing), only here + # on the explicit per-song delete — the never-clobber contract. + appstate.meta_db.conn.execute("DELETE FROM song_enrichment WHERE filename = ?", (cache_key,)) + appstate.meta_db.conn.commit() + + # User art overrides go with the song (CAA cache files are keyed by + # RELEASE and may be shared with other charts — the LRU owns those). + for _p in appstate.art_override_paths(cache_key): + try: + _p.unlink() + except OSError: + pass + + appstate.invalidate_song_caches(cache_key) + + log.info("Deleted song %s", cache_key) + # If a scan was mid-flight when we removed the row, it may already have + # listed (and not yet processed) the file and will call ``appstate.meta_db.put()`` + # for it after our DB delete — reinserting a ghost row. Coalesce a + # follow-up pass via ``appstate.kick_scan`` so the next scan's ``delete_missing()`` + # purges that entry. Cheap no-op when no scan is running. + if appstate.scan_status()["running"]: + appstate.kick_scan() + return {"ok": True, "filename": cache_key} + + +@router.get("/api/song/{filename:path}/user-meta") +def get_song_user_meta(filename: str): + """Read {user_difficulty, notes, tags} for one song.""" + return appstate.meta_db.get_song_user_meta(appstate.meta_db._canonical_song_filename(filename)) + + +@router.put("/api/song/{filename:path}/user-meta") +def put_song_user_meta(filename: str, data: dict): + """Partial update. Send any of: `user_difficulty` (int 1–5, or null/"" to + clear), `notes` (string, or null to clear), `tags` (a full-replace array of + strings). Omitted keys are preserved. Returns the merged meta. + + Tag removal is a full-replace `tags` array (send the new set) rather than a + granular DELETE sub-route, because `DELETE /api/song/{filename:path}` already + owns every DELETE under /api/song and would shadow it.""" + key = appstate.meta_db._canonical_song_filename(filename) + kwargs: dict = {} + if "user_difficulty" in data: + v = data["user_difficulty"] + if v is None or v == "": + kwargs["user_difficulty"] = None + else: + # Reject bools (int subclass) and non-integral floats so 2.5 / true + # can't silently truncate into a valid band. + if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()): + return JSONResponse({"error": "user_difficulty must be an integer 1–5 or null"}, 400) + try: + iv = int(v) + except (TypeError, ValueError): + return JSONResponse({"error": "user_difficulty must be an integer 1–5 or null"}, 400) + if not (1 <= iv <= 5): + return JSONResponse({"error": "user_difficulty must be 1–5 or null"}, 400) + kwargs["user_difficulty"] = iv + if "notes" in data: + n = data["notes"] + if n is None: + kwargs["notes"] = None + elif isinstance(n, str): + kwargs["notes"] = n.strip()[:4000] + else: + return JSONResponse({"error": "notes must be a string or null"}, 400) + tags = data.get("tags", "__absent__") + if tags != "__absent__" and not isinstance(tags, list): + return JSONResponse({"error": "tags must be an array of strings"}, 400) + if not kwargs and tags == "__absent__": + return JSONResponse({"error": "No fields to update"}, 400) + if kwargs: + appstate.meta_db.set_song_user_meta(key, **kwargs) + if tags != "__absent__": + appstate.meta_db.set_song_tags(key, tags) + return appstate.meta_db.get_song_user_meta(key) + + +# Catalog fields the Fix-metadata popup may override/lock — the intersection of +# "displayable identity" and "safe to correct locally". Guitar/practice facts +# and personal fields are never overrides. +_OVERRIDE_FIELDS = frozenset({"title", "artist", "album", "year", "genre"}) + + +@router.get("/api/song/{filename:path}/overrides") +def get_song_overrides(filename: str): + """Per-field metadata overrides + locks for one song (Fix-metadata popup): + {"overrides": {field: {"value": str|null, "locked": bool}}, + "pack": {field: str}}. `pack` is the stored value each override sits on top + of — the popup's Details tab renders it as the revert-to-pack reference and + the Yours/Pack provenance.""" + key = appstate.meta_db._canonical_song_filename(filename) + return {"overrides": appstate.meta_db.get_song_overrides(key), + "pack": appstate.meta_db.pack_fields(key)} + + +@router.put("/api/song/{filename:path}/overrides") +def put_song_overrides(filename: str, data: dict): + """Set/clear per-field overrides + locks. Body: + `{"overrides": {field: {"value": str|null, "locked": bool}}}`. Only catalog + fields (title/artist/album/year/genre) are accepted. A field left with no + value and unlocked is removed. Returns the merged override map. + + Clearing rides this PUT (send value:null, locked:false) rather than a DELETE + sub-route, because `DELETE /api/song/{filename:path}` already owns every + DELETE under /api/song and would shadow it (same reason as tags).""" + ov = (data or {}).get("overrides") + if not isinstance(ov, dict) or not ov: + return JSONResponse({"error": "overrides must be a non-empty object"}, 400) + bad = sorted(f for f in ov if f not in _OVERRIDE_FIELDS) + if bad: + return JSONResponse({"error": "unknown field(s): " + ", ".join(bad)}, 400) + key = appstate.meta_db._canonical_song_filename(filename) + for field, spec in ov.items(): + if not isinstance(spec, dict): + return JSONResponse({"error": f"'{field}' must be an object with value/locked"}, 400) + kwargs: dict = {} + if "value" in spec: + v = spec["value"] + if v is None: + kwargs["value"] = None + elif isinstance(v, (str, int, float)) and not isinstance(v, bool): + kwargs["value"] = str(v).strip()[:500] + else: + return JSONResponse({"error": f"'{field}' value must be a string or null"}, 400) + if "locked" in spec: + kwargs["locked"] = bool(spec["locked"]) + if kwargs: + appstate.meta_db.set_song_override(key, field, **kwargs) + return {"overrides": appstate.meta_db.get_song_overrides(key)} + + +@router.post("/api/songs/user-meta/batch") +def batch_song_user_meta(data: dict): + """Bulk personal-meta edit over a selection — one request instead of N×2 + per-song round-trips (the batch bar's apply-to-all). DB-only; never touches + files. Body: + {"filenames": [...], # required, non-empty + "set_difficulty": 1-5 | null, # optional: set on all / clear on all + "add_tags": [...], # optional: add to all (never full-replace) + "remove_tags": [...]} # optional: remove from all + Omit `set_difficulty` entirely to leave each song's difficulty as-is + (mixed-state "leave unchanged"). Returns {"updated": N, "tags": [...]} so the + caller can refresh the tag-filter list without a second call.""" + fns = data.get("filenames") + if not isinstance(fns, list) or not fns: + return JSONResponse({"error": "filenames must be a non-empty array"}, 400) + if not all(isinstance(f, str) and f for f in fns): + return JSONResponse({"error": "filenames must be non-empty strings"}, 400) + + kwargs: dict = {} + if "set_difficulty" in data: + v = data["set_difficulty"] + if v is None or v == "": + kwargs["set_difficulty"] = None + else: + if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()): + return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400) + try: + iv = int(v) + except (TypeError, ValueError): + return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400) + if not (1 <= iv <= 5): + return JSONResponse({"error": "set_difficulty must be 1–5 or null"}, 400) + kwargs["set_difficulty"] = iv + + add_tags = data.get("add_tags") + remove_tags = data.get("remove_tags") + for name, val in (("add_tags", add_tags), ("remove_tags", remove_tags)): + if val is not None and not isinstance(val, list): + return JSONResponse({"error": f"{name} must be an array of strings"}, 400) + if "set_difficulty" not in data and not add_tags and not remove_tags: + return JSONResponse({"error": "Nothing to apply"}, 400) + + keys = [appstate.meta_db._canonical_song_filename(f) for f in fns] + n = appstate.meta_db.batch_user_meta(keys, add_tags=add_tags, remove_tags=remove_tags, **kwargs) + return {"updated": n, "tags": appstate.meta_db.all_tags()} + + +@router.post("/api/song/{filename:path}/meta") +def update_song_meta(filename: str, data: dict): + """Update song metadata, persisting it back into the underlying file. + + The library scanner re-derives title/artist/album/year from the file + (archive manifest Attributes / sloppak manifest.yaml) on every full rescan, + so a DB-only edit reverts. We write the edit into the file first, then + refresh the cache row (including mtime/size) to match. Loose-folder and + unwritable songs fall back to a DB-only update (which still survives an + incremental rescan via the mtime/size cache hit). + """ + # Canonicalise to the same key get_song_info uses so an update via + # one URL form (e.g. with `..` segments) lands on the row that + # later reads will see. + dlc = _get_dlc_dir() + cache_key = filename + resolved = None + if dlc: + resolved = _resolve_dlc_path(dlc, filename) + if resolved is None: + return JSONResponse({"error": "forbidden"}, 403) + try: + cache_key = resolved.relative_to(dlc.resolve()).as_posix() + except ValueError: + pass + + fields = {k: data[k] for k in ("title", "artist", "album", "year") if k in data} + if not fields: + return {"error": "No fields to update"} + # Normalise the year value so the DB and file stay in sync. The file + # writer (songmeta) coerces empty/non-numeric years to 0, which the + # scanner reads back as "". Store "" in the DB instead of a raw + # non-numeric string so that if the mtime/size are updated (making the + # row cache-fresh) the DB still matches what the scanner would derive. + if "year" in fields: + try: + _yr_int = int(fields["year"]) + except (TypeError, ValueError): + _yr_int = 0 + fields = {**fields, "year": str(_yr_int) if _yr_int else ""} + + # Persist into the file so the edit survives a full rescan. + # Hold _song_io_lock across the existence check and file write so a + # concurrent delete cannot remove the file between our check and the + # repack's atomic replace, and so a concurrent upload cannot be clobbered + # by our atomic rename. archive repack is slow — the lock is held longer + # than a simple upload/delete, but correctness requires serialisation. + persisted = False + with _song_io_lock: + if resolved is not None and resolved.exists(): + try: + import songmeta + persisted = songmeta.write_song_metadata(resolved, fields) + except Exception: + log.warning("metadata file write failed for %s", cache_key, exc_info=True) + + with appstate.meta_db._lock: + updates = [f"{field} = ?" for field in fields] + params = list(fields.values()) + if persisted: + # The file changed — re-stat so an incremental rescan sees a + # consistent cache row instead of re-reading the (now matching) + # file. + try: + mtime, size = appstate.stat_for_cache(resolved) + updates += ["mtime = ?", "size = ?"] + params += [mtime, size] + except OSError: + pass + params.append(cache_key) + appstate.meta_db.conn.execute( + f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params + ) + appstate.meta_db.conn.commit() + + if persisted: + appstate.invalidate_song_caches(cache_key) + # Coalesce a follow-up scan so a mid-flight scan's stale appstate.meta_db.put() + # for this file can't win: if a scan is running appstate.kick_scan() queues a + # pending pass; if not it starts a fresh one. Unconditional to avoid a + # race where the scan finishes between our DB commit and a guarded check. + appstate.kick_scan() + return {"ok": True, "persisted": persisted} + + +# ── Gap-fill: write CONFIRMED missing metadata into the pack (R4a) ──────────── +# The agreed write-back contract (spec-alignment §7): opt-in + user-initiated +# (nothing here runs in the background), adds ABSENT keys only (never replaces +# an author-set value — the writer refuses, and existing manifest bytes are +# preserved verbatim by appending), spec'd-keys allowlist, values only from a +# CONFIRMED identity (an auto/exact match or a user pin — review-tier rows are +# not eligible until a human confirms), atomic write + .bak. Single-song only; +# batch write-back stays an open question with the spec chair. +_GAP_FILL_KEYS = ("album", "year", "genres", "mbid", "isrc") + + +def _gap_fill_manifest_absent(manifest: dict, key: str) -> bool: + """A key is a GAP only when it's genuinely MISSING from the manifest. + + Gap-fill is append-only: the writer's never-clobber guard raises on ANY + key already present, and appending a second `album:` line to a manifest + that already carries `album: ''` would just create a duplicate YAML key. + So a present-but-empty value (None / '' / [] / year 0) is NOT a gap the + append-only writer can fill — offering it in the preview would only lead + to a POST the writer refuses. Present-but-empty keys are therefore left + to the metadata editor (which re-serializes and can replace in place).""" + return key not in manifest + + +def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]: + """What gap-fill could add for this song: (proposals, reason). Empty + proposals explain themselves via reason — 'not-sloppak', 'no-match' + (nothing confirmed yet), 'review' (a human hasn't confirmed the match), + or 'nothing-missing'.""" + if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved): + return {}, "not-sloppak" + row = appstate.meta_db.get_enrichment(cache_key) + if not row or row.get("match_state") not in ("matched", "manual"): + state = (row or {}).get("match_state") + return {}, ("review" if state == "review" else "no-match") + try: + manifest = sloppak_mod.load_manifest(resolved) or {} + except Exception: + return {}, "not-sloppak" + # A LOCKED field (Fix-metadata popup) is never gap-filled — the user pinned + # it away from the matched value, so writing that value to the file would + # be exactly the clobber the lock exists to prevent. (The lock field name is + # `genre`; the manifest/gap-fill key is `genres`.) + locked = appstate.meta_db.locked_fields(cache_key) + out = {} + album = (row.get("canon_album") or "").strip() + if album and "album" not in locked and _gap_fill_manifest_absent(manifest, "album"): + out["album"] = album + year = (row.get("canon_year") or "").strip() + if (year.isdigit() and int(year) and "year" not in locked + and _gap_fill_manifest_absent(manifest, "year")): + out["year"] = int(year) + genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()] + if genres and "genre" not in locked and _gap_fill_manifest_absent(manifest, "genres"): + out["genres"] = genres + # Identity keys (feedpak spec 1.14.0) — written in canonical form only. + mbid = (row.get("mb_recording_id") or "").strip().lower() + if enrichment._MBID_RE.match(mbid) and _gap_fill_manifest_absent(manifest, "mbid"): + out["mbid"] = mbid + isrc = (row.get("isrc") or "").strip().upper().replace("-", "").replace(" ", "") + if enrichment._ISRC_RE.match(isrc) and _gap_fill_manifest_absent(manifest, "isrc"): + out["isrc"] = isrc + return out, ("" if out else "nothing-missing") + + +@router.get("/api/song/{filename:path}/gap-fill") +def get_song_gap_fill(filename: str): + """Preview what "Write missing info to file" would add — the Details + drawer renders its confirm list straight from this. Read-only.""" + dlc = _get_dlc_dir() + cache_key, resolved = filename, None + if dlc: + resolved = _resolve_dlc_path(dlc, filename) + if resolved is None: + return JSONResponse({"error": "forbidden"}, 403) + try: + cache_key = resolved.relative_to(dlc.resolve()).as_posix() + except ValueError: + pass + proposals, reason = _gap_fill_proposals(cache_key, resolved) + row = appstate.meta_db.get_enrichment(cache_key) or {} + return { + "eligible": bool(proposals), + "reason": reason, + "match_state": row.get("match_state"), + "missing": [{"key": k, "value": v} for k, v in proposals.items()], + } + + +@router.post("/api/song/{filename:path}/gap-fill") +def post_song_gap_fill(filename: str, data: dict): + """Write the user-confirmed subset of the preview into the pack file. + Proposals are recomputed under the io lock, so a key that gained an + author value between preview and confirm is skipped, never replaced.""" + keys = (data or {}).get("keys") + if not isinstance(keys, list) or not keys: + return JSONResponse({"error": "keys must be a non-empty list"}, 400) + bad = [k for k in keys if k not in _GAP_FILL_KEYS] + if bad: + return JSONResponse( + {"error": "unknown key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400) + + dlc = _get_dlc_dir() + cache_key, resolved = filename, None + if dlc: + resolved = _resolve_dlc_path(dlc, filename) + if resolved is None: + return JSONResponse({"error": "forbidden"}, 403) + try: + cache_key = resolved.relative_to(dlc.resolve()).as_posix() + except ValueError: + pass + + with _song_io_lock: + proposals, reason = _gap_fill_proposals(cache_key, resolved) + additions = {k: proposals[k] for k in _GAP_FILL_KEYS if k in keys and k in proposals} + skipped = sorted(set(keys) - set(additions)) + if not additions: + return JSONResponse({"error": "nothing to write", "reason": reason, + "skipped": skipped}, 409) + try: + import songmeta + songmeta.gap_fill_sloppak(resolved, additions) + except Exception: + log.warning("gap-fill write failed for %s", cache_key, exc_info=True) + return JSONResponse({"error": "write failed"}, 500) + + # Keep the cache row consistent with what the scanner would now derive + # (same contract as the metadata editor above): sync the columns the + # scan reads from the keys we appended, then re-stat so the row stays + # cache-fresh. + fields = {} + if "album" in additions: + fields["album"] = additions["album"] + if "year" in additions: + fields["year"] = str(additions["year"]) + if "genres" in additions: + fields["genre"] = additions["genres"][0] + with appstate.meta_db._lock: + updates = [f"{field} = ?" for field in fields] + params = list(fields.values()) + try: + mtime, size = appstate.stat_for_cache(resolved) + updates += ["mtime = ?", "size = ?"] + params += [mtime, size] + except OSError: + pass + if updates: + params.append(cache_key) + appstate.meta_db.conn.execute( + f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params) + appstate.meta_db.conn.commit() + + appstate.invalidate_song_caches(cache_key) + appstate.kick_scan() + return {"ok": True, "written": additions, "skipped": skipped} + + +@router.get("/api/song/{filename:path}") +async def get_song_info(filename: str): + """Return song metadata, from cache or by extracting it from the song source.""" + import asyncio + dlc = _get_dlc_dir() + if not dlc: + return JSONResponse({"error": "DLC folder not configured"}, 404) + + song_path = _resolve_dlc_path(dlc, filename) + if song_path is None: + return JSONResponse({"error": "forbidden"}, 403) + if not song_path.exists(): + return JSONResponse({"error": "File not found"}, 404) + + # Canonicalise the cache key against the resolved path so two URL + # forms of the same physical file (e.g. `Artist/song.sloppak` vs + # `Artist/../Artist/song.sloppak`) converge on a single row instead + # of fragmenting / shadowing each other in appstate.meta_db. + try: + cache_key = song_path.relative_to(dlc.resolve()).as_posix() + except ValueError: + cache_key = filename + + mtime, size = appstate.stat_for_cache(song_path) + cached = appstate.meta_db.get(cache_key, mtime, size) + if cached: + return cached + + # Extract in thread pool + def _extract(): + meta = _extract_meta_for_file(song_path, dlc) + appstate.meta_db.put(cache_key, mtime, size, meta) + return meta + + meta = await asyncio.get_event_loop().run_in_executor(None, _extract) + return meta diff --git a/server.py b/server.py index 24c9ab4..324c154 100644 --- a/server.py +++ b/server.py @@ -54,6 +54,7 @@ from routers import tunings as tunings_router import enrichment from routers import art as art_router from routers import settings as settings_router +from routers import song as song_router import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -2532,7 +2533,7 @@ async def api_enrichment_identify(request: Request): cl_int = int(cl) except ValueError: return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) - if cl_int > enrichment._ACOUSTID_MAX_UPLOAD_BYTES + _MULTIPART_OVERHEAD_SLACK: + if cl_int > enrichment._ACOUSTID_MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK: return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413) try: form = await request.form(max_part_size=enrichment._ACOUSTID_MAX_UPLOAD_BYTES) @@ -2676,60 +2677,8 @@ def trigger_full_rescan(): # ── Song upload ─────────────────────────────────────────────────────────────── _ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS) -_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems -# Per-request batch cap. Lets a user drop a whole album of sloppaks at once -# without giving a hostile client a 1000-file DoS surface via Starlette's -# default max_files=1000. The pre-parse Content-Length guard is sized as -# _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + slack. -_MAX_UPLOAD_FILES = 50 -# Multipart Content-Length includes boundary markers + per-part headers, so a -# file sitting right at _MAX_UPLOAD_BYTES would be rejected by an equality cap -# on Content-Length. Add a generous slack for the multipart envelope; the real -# file-size cap is enforced by the streaming check in _save_uploaded_song(). -_MULTIPART_OVERHEAD_SLACK = 1024 * 1024 # 1 MiB -# Serializes the mutating step of upload (os.replace into DLC_DIR) with -# delete_song so the two endpoints can't interleave on the same path — -# e.g. an upload finishing right after a concurrent delete shouldn't -# resurrect a song the user just removed, and a delete arriving mid- -# overwrite shouldn't strand a half-written file. threading.Lock (not -# asyncio.Lock) because delete_song is sync (runs in the threadpool); -# upload acquires it inside ``run_in_threadpool`` for the same reason. -_song_io_lock = threading.Lock() -def _commit_uploaded_song(tmp_path: Path, dest: Path, overwrite: bool, base: str): - """Atomically move a validated temp upload into ``dest`` under ``_song_io_lock``. - - Returns ``None`` on success or an error result dict matching the upload - endpoint's contract. Holds the lock across the directory re-check and - the final ``os.replace`` so a concurrent delete or upload can't slip - between them. Always cleans up the temp file on the error paths. - """ - with _song_io_lock: - if dest.exists(): - if not overwrite: - # Lost the race against a concurrent upload of the same name. - try: - tmp_path.unlink() - except OSError: - pass - return {"status": "exists", "filename": base, - "error": "A file with this name already exists"} - # Re-check directory state under the lock — the pre-check - # may have raced an unrelated mkdir, and a sloppak directory - # has to be removed before os.replace() can write over it. - if dest.is_dir(): - if not sloppak_mod.is_sloppak(dest): - try: - tmp_path.unlink() - except OSError: - pass - return {"status": "exists", "filename": base, - "error": "A directory with this name exists and is not " - "a sloppak — refusing to overwrite"} - shutil.rmtree(str(dest)) - os.replace(str(tmp_path), str(dest)) - return None def _invalidate_song_caches(cache_key: str) -> None: @@ -2770,349 +2719,20 @@ def _invalidate_song_caches(cache_key: str) -> None: log.debug("failed to evict audio cache file %s", f, exc_info=True) -@app.post("/api/songs/upload") -async def upload_song(request: Request): - """Upload one or more .sloppak files into the configured DLC folder. - - Multipart body with one or more ``file`` fields (up to ``_MAX_UPLOAD_FILES`` - per request). Query string: - ``overwrite=1`` — replace existing files with the same name. - - Response shape (always HTTP 200 once we've gotten past request-level guards - like DLC-not-configured / payload-too-large): - ``{"results": [{"filename": "...", "status": "ok" | "exists" | "error", - "error"?: "...", "size"?: N, "format"?: "sloppak"}, ...]}`` - Per-file conflicts surface as ``status: "exists"`` so a batch upload can - surface ALL conflicts at once instead of bailing on the first one. The - client re-POSTs just the conflicting files with ``overwrite=1`` if the - user opts in. - - The DLC directory is resolved via ``_get_dlc_dir()`` which honours the - ``DLC_DIR`` env var first and falls back to ``dlc_dir`` in - ``config.json`` — so uploads land in whichever folder the rest of the - app already considers the library root, regardless of which mechanism - configured it. - """ - dlc = _get_dlc_dir() - if dlc is None: - return JSONResponse( - {"error": "DLC folder is not configured. Set DLC_DIR or configure it in Settings."}, - status_code=503, - ) - if not os.access(str(dlc), os.W_OK): - return JSONResponse( - {"error": f"DLC folder {dlc} is not writable by the server process."}, - status_code=500, - ) - - # Pre-parse Content-Length guard — fail fast before reading any body. - # Multipart Content-Length is file bytes + boundary + per-part headers, so - # we can't use _MAX_UPLOAD_BYTES as an exact cap here (a file right at the - # advertised max would be rejected before _save_uploaded_song() can apply - # the real per-file byte cap). For batch uploads we allow up to - # _MAX_UPLOAD_FILES files at _MAX_UPLOAD_BYTES each; the parser still - # enforces per-part size via max_part_size and per-batch count via - # max_files. The streaming check inside _save_uploaded_song() is the - # authoritative per-file size cap. - max_total = _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + _MULTIPART_OVERHEAD_SLACK - cl = request.headers.get("content-length") - if cl is not None: - try: - cl_int = int(cl) - except ValueError: - return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) - if cl_int < 0: - return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) - if cl_int > max_total: - return JSONResponse( - {"error": f"Batch upload exceeds {_MAX_UPLOAD_FILES} files × " - f"{_MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit"}, - status_code=413, - ) - - overwrite = request.query_params.get("overwrite") == "1" - # Tighten the parser to the handler's contract: up to _MAX_UPLOAD_FILES - # file parts, no text parts (overwrite comes from query params). - # Starlette's defaults of max_files=1000 / max_fields=1000 would - # otherwise let a client force the parser to spool far more parts than - # the endpoint is willing to process. - form = await request.form( - max_files=_MAX_UPLOAD_FILES, - max_fields=0, - max_part_size=_MAX_UPLOAD_BYTES, - ) - try: - from starlette.datastructures import UploadFile as _StarletteUploadFile - # form.getlist("file") returns all parts named "file" in submission - # order. Filter to file parts only — Starlette would yield strings - # for text parts, but we've capped max_fields=0 so any non-file part - # is already a parser error before reaching here. - uploads = [u for u in form.getlist("file") if isinstance(u, _StarletteUploadFile)] - if not uploads: - return JSONResponse( - {"error": "Expected one or more files in multipart field 'file'"}, - status_code=400, - ) - - results = [] - any_saved = False - for upload in uploads: - try: - result = await _save_uploaded_song(upload, dlc, overwrite) - results.append(result) - if result.get("status") == "ok": - any_saved = True - except Exception as e: - # Per-file failure must not abort the batch — record and - # continue so the client gets a complete report. - log.exception("upload failed for %r", getattr(upload, "filename", "?")) - results.append({ - "filename": Path(getattr(upload, "filename", "") or "").name or "?", - "status": "error", - "error": f"Upload failed: {e}", - }) - finally: - try: - await upload.close() - except Exception: - log.debug("failed to close upload file handle", exc_info=True) - - if any_saved: - _kick_scan() - return {"results": results} - finally: - try: - await form.close() - except Exception: - log.debug("failed to close form", exc_info=True) +# Publish the scan/ingest seam for routers/song.py. These stay here (the scan +# lifecycle owns them); scan_status is a getter so the reassigned dict stays live. +appstate.configure( + kick_scan=_kick_scan, + invalidate_song_caches=_invalidate_song_caches, + stat_for_cache=_stat_for_cache, + scan_status=lambda: _scan_status, +) -async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) -> dict: - """Save one upload into ``dlc``. Returns a per-file result dict (never - a JSONResponse) so batch uploads can aggregate. - - Shape: - ok: ``{"status": "ok", "filename": base, "size": N, "format": "sloppak"}`` - exists: ``{"status": "exists", "filename": base, "error": "..."}`` - error: ``{"status": "error", "filename": base, "error": "..."}`` - """ - # Strip any path components a client may have included in the filename — - # only the basename lands in the DLC root. Path traversal would otherwise - # let a crafted upload escape the library directory. - raw_name = upload.filename or "" - base = Path(raw_name).name - if not base or base in (".", "..") or "/" in base or "\\" in base: - return {"status": "error", "filename": raw_name or "?", "error": "Invalid filename"} - suffix = Path(base).suffix.lower() - if suffix not in _ALLOWED_SONG_EXTS: - return {"status": "error", "filename": base, - "error": "Only .feedpak files are accepted"} - - dest = dlc / base - if dest.exists(): - if not overwrite: - return {"status": "exists", "filename": base, - "error": "A file with this name already exists"} - # overwrite=1 must handle directory-form sloppaks (the scanner and - # delete path both treat them as song entries). os.replace() can't - # clobber a non-empty directory, so without the rmtree below the - # whole upload would write to a temp file and then surface a late - # 500 at the os.replace() call. Refuse other directories so an - # unrelated folder isn't blown away by a same-named upload. - if dest.is_dir() and not sloppak_mod.is_sloppak(dest): - return {"status": "exists", "filename": base, - "error": "A directory with this name exists and is not a sloppak — " - "refusing to overwrite"} - - # Temp file in the DLC dir itself so os.replace is atomic (same filesystem). - # Dot-prefix keeps it out of the rglob("*.sloppak") scan glob. - fd, tmp_name = await run_in_threadpool( - tempfile.mkstemp, dir=str(dlc), prefix=".upload-", suffix=".part" - ) - tmp_path = Path(tmp_name) - bytes_read = 0 - head = b"" - error_result: dict | None = None - try: - try: - tmpf = await run_in_threadpool(os.fdopen, fd, "wb") - except BaseException: - try: - await run_in_threadpool(os.close, fd) - except OSError: - pass - raise - try: - while True: - chunk = await upload.read(1024 * 1024) - if not chunk: - break - bytes_read += len(chunk) - if bytes_read > _MAX_UPLOAD_BYTES: - error_result = { - "status": "error", "filename": base, - "error": f"Upload exceeds {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB cap", - } - break - if len(head) < 4: - head += chunk[: 4 - len(head)] - await run_in_threadpool(tmpf.write, chunk) - finally: - await run_in_threadpool(tmpf.close) - - if error_result is None: - if bytes_read == 0: - error_result = {"status": "error", "filename": base, - "error": "Empty upload — file is 0 bytes"} - elif suffix in _ALLOWED_SONG_EXTS: - if head[:2] != b"PK": - error_result = {"status": "error", "filename": base, - "error": "Not a valid feedpak file (expected zip archive)"} - else: - # ZIP magic alone admits any renamed zip — verify the sloppak - # loader can actually parse a manifest.yaml inside. Without - # this, /api/songs/upload returns "ok" for files the rest of - # the backend would refuse to scan or load. - try: - await run_in_threadpool(sloppak_mod.load_manifest, tmp_path) - except Exception as e: - error_result = {"status": "error", "filename": base, - "error": f"Not a valid sloppak file: {e}"} - - if error_result is not None: - try: - await run_in_threadpool(tmp_path.unlink) - except OSError: - pass - return error_result - - # Single sync helper so the lock is held for the whole commit — - # ``async with _upload_lock`` would have released between every - # ``run_in_threadpool`` and let a concurrent delete or upload slip - # in between the dir check and the final ``os.replace``. - commit_result = await run_in_threadpool( - _commit_uploaded_song, tmp_path, dest, overwrite, base - ) - if commit_result is not None: - return commit_result - except BaseException: - try: - await run_in_threadpool(tmp_path.unlink) - except OSError: - pass - raise - - # Even on a fresh (non-overwrite) upload, evict any stale entries left - # over from a previous delete+re-upload of the same name. - await run_in_threadpool(_invalidate_song_caches, base) - - log.info("Uploaded %s (%d bytes) to %s", base, bytes_read, dlc) - return {"status": "ok", "filename": base, "size": bytes_read, - "format": suffix.lstrip(".")} -@app.delete("/api/song/{filename:path}") -def delete_song(filename: str): - """Remove a song from the DLC folder and clear its cache entries. - Works for both formats: ``.sloppak`` files OR directories, and - loose-folder songs (the directory containing the chart). The path is - resolved through ``_resolve_dlc_path`` so URL-encoded ``..`` segments - cannot escape the library root. - """ - dlc = _get_dlc_dir() - if dlc is None: - return JSONResponse({"error": "DLC folder not configured"}, status_code=503) - resolved = _resolve_dlc_path(dlc, filename) - if resolved is None: - return JSONResponse({"error": "forbidden"}, status_code=403) - if not resolved.exists(): - return JSONResponse({"error": "File not found"}, status_code=404) - if resolved == dlc.resolve(): - return JSONResponse({"error": "Refusing to delete the DLC root"}, status_code=400) - # Only delete actual song entries. Without this, DELETE /api/song/ArtistName - # would recursively wipe a whole artist subfolder — far broader than the - # UI's per-song contract. Sloppak detection wins over loose because a - # sloppak dir can also contain WEM/XML (matches the scanner's precedence). - is_sloppak = sloppak_mod.is_sloppak(resolved) - is_loose = ( - resolved.is_dir() - and not is_sloppak - and loosefolder_mod.is_loose_song(resolved) - ) - if not (is_sloppak or is_loose): - return JSONResponse( - {"error": "Not a song entry — only sloppaks " - "or loose-folder songs can be deleted"}, - status_code=400, - ) - - # Hold ``_song_io_lock`` across the filesystem removal AND the DB/cache - # eviction. Without it, an upload of the same filename could ``os.replace`` - # a new file into place between our removal and DB delete, leaving the - # new generation stranded with no library row; or the reverse, where - # delete runs between an upload's directory check and its replace and - # the upload then resurrects the song we just removed. - with _song_io_lock: - try: - if resolved.is_dir(): - shutil.rmtree(resolved) - else: - resolved.unlink() - except OSError as e: - log.error("Failed to delete %s: %s", resolved, e) - return JSONResponse({"error": f"Delete failed: {e}"}, status_code=500) - - # Canonicalise the cache key the same way update_song_meta does so we - # hit the row the scanner indexed under. - try: - cache_key = resolved.relative_to(dlc.resolve()).as_posix() - except ValueError: - cache_key = filename - with meta_db._lock: - meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", (cache_key,)) - meta_db.conn.execute("DELETE FROM favorites WHERE filename = ?", (cache_key,)) - meta_db.conn.execute("DELETE FROM loops WHERE filename = ?", (cache_key,)) - # Purge the v3 filename-keyed state too, so the deleted song stops - # surfacing in stats / recent / continue / playlists immediately. - meta_db.conn.execute("DELETE FROM song_stats WHERE filename = ?", (cache_key,)) - meta_db.conn.execute("DELETE FROM playlist_songs WHERE filename = ?", (cache_key,)) - # Personal difficulty / notes / tags for this song (we hold the - # lock, so purge is lock-free). - meta_db.purge_song_user_data(cache_key) - # Multi-chart grouping (P5a): drop this chart's split + read-model rows, - # and any preferred-chart pointer that named it (the work re-auto-picks). - # work_key-keyed prefs for OTHER charts survive. Mark the read-model - # dirty so the affected work regroups on the next grouped query. - meta_db.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (cache_key,)) - meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,)) - meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,)) - meta_db._work_display_dirty = True - # Enrichment is never purged on rescan (delete_missing), only here - # on the explicit per-song delete — the never-clobber contract. - meta_db.conn.execute("DELETE FROM song_enrichment WHERE filename = ?", (cache_key,)) - meta_db.conn.commit() - - # User art overrides go with the song (CAA cache files are keyed by - # RELEASE and may be shared with other charts — the LRU owns those). - for _p in _art_override_paths(cache_key): - try: - _p.unlink() - except OSError: - pass - - _invalidate_song_caches(cache_key) - - log.info("Deleted song %s", cache_key) - # If a scan was mid-flight when we removed the row, it may already have - # listed (and not yet processed) the file and will call ``meta_db.put()`` - # for it after our DB delete — reinserting a ghost row. Coalesce a - # follow-up pass via ``_kick_scan`` so the next scan's ``delete_missing()`` - # purges that entry. Cheap no-op when no scan is running. - if _scan_status["running"]: - _kick_scan() - return {"ok": True, "filename": cache_key} # ── Library API ─────────────────────────────────────────────────────────────── @@ -3402,158 +3022,16 @@ async def list_tuning_names(provider: str = "local"): # these endpoints are DB-only and never touch the file. Likes stay the heart # (POST /api/favorites/toggle). -@app.get("/api/song/{filename:path}/user-meta") -def get_song_user_meta(filename: str): - """Read {user_difficulty, notes, tags} for one song.""" - return meta_db.get_song_user_meta(meta_db._canonical_song_filename(filename)) -@app.put("/api/song/{filename:path}/user-meta") -def put_song_user_meta(filename: str, data: dict): - """Partial update. Send any of: `user_difficulty` (int 1–5, or null/"" to - clear), `notes` (string, or null to clear), `tags` (a full-replace array of - strings). Omitted keys are preserved. Returns the merged meta. - - Tag removal is a full-replace `tags` array (send the new set) rather than a - granular DELETE sub-route, because `DELETE /api/song/{filename:path}` already - owns every DELETE under /api/song and would shadow it.""" - key = meta_db._canonical_song_filename(filename) - kwargs: dict = {} - if "user_difficulty" in data: - v = data["user_difficulty"] - if v is None or v == "": - kwargs["user_difficulty"] = None - else: - # Reject bools (int subclass) and non-integral floats so 2.5 / true - # can't silently truncate into a valid band. - if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()): - return JSONResponse({"error": "user_difficulty must be an integer 1–5 or null"}, 400) - try: - iv = int(v) - except (TypeError, ValueError): - return JSONResponse({"error": "user_difficulty must be an integer 1–5 or null"}, 400) - if not (1 <= iv <= 5): - return JSONResponse({"error": "user_difficulty must be 1–5 or null"}, 400) - kwargs["user_difficulty"] = iv - if "notes" in data: - n = data["notes"] - if n is None: - kwargs["notes"] = None - elif isinstance(n, str): - kwargs["notes"] = n.strip()[:4000] - else: - return JSONResponse({"error": "notes must be a string or null"}, 400) - tags = data.get("tags", "__absent__") - if tags != "__absent__" and not isinstance(tags, list): - return JSONResponse({"error": "tags must be an array of strings"}, 400) - if not kwargs and tags == "__absent__": - return JSONResponse({"error": "No fields to update"}, 400) - if kwargs: - meta_db.set_song_user_meta(key, **kwargs) - if tags != "__absent__": - meta_db.set_song_tags(key, tags) - return meta_db.get_song_user_meta(key) -# Catalog fields the Fix-metadata popup may override/lock — the intersection of -# "displayable identity" and "safe to correct locally". Guitar/practice facts -# and personal fields are never overrides. -_OVERRIDE_FIELDS = frozenset({"title", "artist", "album", "year", "genre"}) -@app.get("/api/song/{filename:path}/overrides") -def get_song_overrides(filename: str): - """Per-field metadata overrides + locks for one song (Fix-metadata popup): - {"overrides": {field: {"value": str|null, "locked": bool}}, - "pack": {field: str}}. `pack` is the stored value each override sits on top - of — the popup's Details tab renders it as the revert-to-pack reference and - the Yours/Pack provenance.""" - key = meta_db._canonical_song_filename(filename) - return {"overrides": meta_db.get_song_overrides(key), - "pack": meta_db.pack_fields(key)} -@app.put("/api/song/{filename:path}/overrides") -def put_song_overrides(filename: str, data: dict): - """Set/clear per-field overrides + locks. Body: - `{"overrides": {field: {"value": str|null, "locked": bool}}}`. Only catalog - fields (title/artist/album/year/genre) are accepted. A field left with no - value and unlocked is removed. Returns the merged override map. - - Clearing rides this PUT (send value:null, locked:false) rather than a DELETE - sub-route, because `DELETE /api/song/{filename:path}` already owns every - DELETE under /api/song and would shadow it (same reason as tags).""" - ov = (data or {}).get("overrides") - if not isinstance(ov, dict) or not ov: - return JSONResponse({"error": "overrides must be a non-empty object"}, 400) - bad = sorted(f for f in ov if f not in _OVERRIDE_FIELDS) - if bad: - return JSONResponse({"error": "unknown field(s): " + ", ".join(bad)}, 400) - key = meta_db._canonical_song_filename(filename) - for field, spec in ov.items(): - if not isinstance(spec, dict): - return JSONResponse({"error": f"'{field}' must be an object with value/locked"}, 400) - kwargs: dict = {} - if "value" in spec: - v = spec["value"] - if v is None: - kwargs["value"] = None - elif isinstance(v, (str, int, float)) and not isinstance(v, bool): - kwargs["value"] = str(v).strip()[:500] - else: - return JSONResponse({"error": f"'{field}' value must be a string or null"}, 400) - if "locked" in spec: - kwargs["locked"] = bool(spec["locked"]) - if kwargs: - meta_db.set_song_override(key, field, **kwargs) - return {"overrides": meta_db.get_song_overrides(key)} -@app.post("/api/songs/user-meta/batch") -def batch_song_user_meta(data: dict): - """Bulk personal-meta edit over a selection — one request instead of N×2 - per-song round-trips (the batch bar's apply-to-all). DB-only; never touches - files. Body: - {"filenames": [...], # required, non-empty - "set_difficulty": 1-5 | null, # optional: set on all / clear on all - "add_tags": [...], # optional: add to all (never full-replace) - "remove_tags": [...]} # optional: remove from all - Omit `set_difficulty` entirely to leave each song's difficulty as-is - (mixed-state "leave unchanged"). Returns {"updated": N, "tags": [...]} so the - caller can refresh the tag-filter list without a second call.""" - fns = data.get("filenames") - if not isinstance(fns, list) or not fns: - return JSONResponse({"error": "filenames must be a non-empty array"}, 400) - if not all(isinstance(f, str) and f for f in fns): - return JSONResponse({"error": "filenames must be non-empty strings"}, 400) - - kwargs: dict = {} - if "set_difficulty" in data: - v = data["set_difficulty"] - if v is None or v == "": - kwargs["set_difficulty"] = None - else: - if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()): - return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400) - try: - iv = int(v) - except (TypeError, ValueError): - return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400) - if not (1 <= iv <= 5): - return JSONResponse({"error": "set_difficulty must be 1–5 or null"}, 400) - kwargs["set_difficulty"] = iv - - add_tags = data.get("add_tags") - remove_tags = data.get("remove_tags") - for name, val in (("add_tags", add_tags), ("remove_tags", remove_tags)): - if val is not None and not isinstance(val, list): - return JSONResponse({"error": f"{name} must be an array of strings"}, 400) - if "set_difficulty" not in data and not add_tags and not remove_tags: - return JSONResponse({"error": "Nothing to apply"}, 400) - - keys = [meta_db._canonical_song_filename(f) for f in fns] - n = meta_db.batch_user_meta(keys, add_tags=add_tags, remove_tags=remove_tags, **kwargs) - return {"updated": n, "tags": meta_db.all_tags()} @@ -3975,6 +3453,14 @@ app.include_router(diagnostics.router) # ── Album-art routes → routers/art.py (R3) ────────────────────────────────── app.include_router(art_router.router) +# Song routes mount AFTER art (and every other /api/song/{path}/… route): its +# get_song_info catch-all `/api/song/{filename:path}` would otherwise shadow them. +app.include_router(song_router.router) + + + + + @@ -3993,247 +3479,6 @@ app.include_router(art_router.router) -@app.post("/api/song/{filename:path}/meta") -def update_song_meta(filename: str, data: dict): - """Update song metadata, persisting it back into the underlying file. - - The library scanner re-derives title/artist/album/year from the file - (archive manifest Attributes / sloppak manifest.yaml) on every full rescan, - so a DB-only edit reverts. We write the edit into the file first, then - refresh the cache row (including mtime/size) to match. Loose-folder and - unwritable songs fall back to a DB-only update (which still survives an - incremental rescan via the mtime/size cache hit). - """ - # Canonicalise to the same key get_song_info uses so an update via - # one URL form (e.g. with `..` segments) lands on the row that - # later reads will see. - dlc = _get_dlc_dir() - cache_key = filename - resolved = None - if dlc: - resolved = _resolve_dlc_path(dlc, filename) - if resolved is None: - return JSONResponse({"error": "forbidden"}, 403) - try: - cache_key = resolved.relative_to(dlc.resolve()).as_posix() - except ValueError: - pass - - fields = {k: data[k] for k in ("title", "artist", "album", "year") if k in data} - if not fields: - return {"error": "No fields to update"} - # Normalise the year value so the DB and file stay in sync. The file - # writer (songmeta) coerces empty/non-numeric years to 0, which the - # scanner reads back as "". Store "" in the DB instead of a raw - # non-numeric string so that if the mtime/size are updated (making the - # row cache-fresh) the DB still matches what the scanner would derive. - if "year" in fields: - try: - _yr_int = int(fields["year"]) - except (TypeError, ValueError): - _yr_int = 0 - fields = {**fields, "year": str(_yr_int) if _yr_int else ""} - - # Persist into the file so the edit survives a full rescan. - # Hold _song_io_lock across the existence check and file write so a - # concurrent delete cannot remove the file between our check and the - # repack's atomic replace, and so a concurrent upload cannot be clobbered - # by our atomic rename. archive repack is slow — the lock is held longer - # than a simple upload/delete, but correctness requires serialisation. - persisted = False - with _song_io_lock: - if resolved is not None and resolved.exists(): - try: - import songmeta - persisted = songmeta.write_song_metadata(resolved, fields) - except Exception: - log.warning("metadata file write failed for %s", cache_key, exc_info=True) - - with meta_db._lock: - updates = [f"{field} = ?" for field in fields] - params = list(fields.values()) - if persisted: - # The file changed — re-stat so an incremental rescan sees a - # consistent cache row instead of re-reading the (now matching) - # file. - try: - mtime, size = _stat_for_cache(resolved) - updates += ["mtime = ?", "size = ?"] - params += [mtime, size] - except OSError: - pass - params.append(cache_key) - meta_db.conn.execute( - f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params - ) - meta_db.conn.commit() - - if persisted: - _invalidate_song_caches(cache_key) - # Coalesce a follow-up scan so a mid-flight scan's stale meta_db.put() - # for this file can't win: if a scan is running _kick_scan() queues a - # pending pass; if not it starts a fresh one. Unconditional to avoid a - # race where the scan finishes between our DB commit and a guarded check. - _kick_scan() - return {"ok": True, "persisted": persisted} - - -# ── Gap-fill: write CONFIRMED missing metadata into the pack (R4a) ──────────── -# The agreed write-back contract (spec-alignment §7): opt-in + user-initiated -# (nothing here runs in the background), adds ABSENT keys only (never replaces -# an author-set value — the writer refuses, and existing manifest bytes are -# preserved verbatim by appending), spec'd-keys allowlist, values only from a -# CONFIRMED identity (an auto/exact match or a user pin — review-tier rows are -# not eligible until a human confirms), atomic write + .bak. Single-song only; -# batch write-back stays an open question with the spec chair. -_GAP_FILL_KEYS = ("album", "year", "genres", "mbid", "isrc") - - -def _gap_fill_manifest_absent(manifest: dict, key: str) -> bool: - """A key is a GAP only when it's genuinely MISSING from the manifest. - - Gap-fill is append-only: the writer's never-clobber guard raises on ANY - key already present, and appending a second `album:` line to a manifest - that already carries `album: ''` would just create a duplicate YAML key. - So a present-but-empty value (None / '' / [] / year 0) is NOT a gap the - append-only writer can fill — offering it in the preview would only lead - to a POST the writer refuses. Present-but-empty keys are therefore left - to the metadata editor (which re-serializes and can replace in place).""" - return key not in manifest - - -def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]: - """What gap-fill could add for this song: (proposals, reason). Empty - proposals explain themselves via reason — 'not-sloppak', 'no-match' - (nothing confirmed yet), 'review' (a human hasn't confirmed the match), - or 'nothing-missing'.""" - if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved): - return {}, "not-sloppak" - row = meta_db.get_enrichment(cache_key) - if not row or row.get("match_state") not in ("matched", "manual"): - state = (row or {}).get("match_state") - return {}, ("review" if state == "review" else "no-match") - try: - manifest = sloppak_mod.load_manifest(resolved) or {} - except Exception: - return {}, "not-sloppak" - # A LOCKED field (Fix-metadata popup) is never gap-filled — the user pinned - # it away from the matched value, so writing that value to the file would - # be exactly the clobber the lock exists to prevent. (The lock field name is - # `genre`; the manifest/gap-fill key is `genres`.) - locked = meta_db.locked_fields(cache_key) - out = {} - album = (row.get("canon_album") or "").strip() - if album and "album" not in locked and _gap_fill_manifest_absent(manifest, "album"): - out["album"] = album - year = (row.get("canon_year") or "").strip() - if (year.isdigit() and int(year) and "year" not in locked - and _gap_fill_manifest_absent(manifest, "year")): - out["year"] = int(year) - genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()] - if genres and "genre" not in locked and _gap_fill_manifest_absent(manifest, "genres"): - out["genres"] = genres - # Identity keys (feedpak spec 1.14.0) — written in canonical form only. - mbid = (row.get("mb_recording_id") or "").strip().lower() - if enrichment._MBID_RE.match(mbid) and _gap_fill_manifest_absent(manifest, "mbid"): - out["mbid"] = mbid - isrc = (row.get("isrc") or "").strip().upper().replace("-", "").replace(" ", "") - if enrichment._ISRC_RE.match(isrc) and _gap_fill_manifest_absent(manifest, "isrc"): - out["isrc"] = isrc - return out, ("" if out else "nothing-missing") - - -@app.get("/api/song/{filename:path}/gap-fill") -def get_song_gap_fill(filename: str): - """Preview what "Write missing info to file" would add — the Details - drawer renders its confirm list straight from this. Read-only.""" - dlc = _get_dlc_dir() - cache_key, resolved = filename, None - if dlc: - resolved = _resolve_dlc_path(dlc, filename) - if resolved is None: - return JSONResponse({"error": "forbidden"}, 403) - try: - cache_key = resolved.relative_to(dlc.resolve()).as_posix() - except ValueError: - pass - proposals, reason = _gap_fill_proposals(cache_key, resolved) - row = meta_db.get_enrichment(cache_key) or {} - return { - "eligible": bool(proposals), - "reason": reason, - "match_state": row.get("match_state"), - "missing": [{"key": k, "value": v} for k, v in proposals.items()], - } - - -@app.post("/api/song/{filename:path}/gap-fill") -def post_song_gap_fill(filename: str, data: dict): - """Write the user-confirmed subset of the preview into the pack file. - Proposals are recomputed under the io lock, so a key that gained an - author value between preview and confirm is skipped, never replaced.""" - keys = (data or {}).get("keys") - if not isinstance(keys, list) or not keys: - return JSONResponse({"error": "keys must be a non-empty list"}, 400) - bad = [k for k in keys if k not in _GAP_FILL_KEYS] - if bad: - return JSONResponse( - {"error": "unknown key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400) - - dlc = _get_dlc_dir() - cache_key, resolved = filename, None - if dlc: - resolved = _resolve_dlc_path(dlc, filename) - if resolved is None: - return JSONResponse({"error": "forbidden"}, 403) - try: - cache_key = resolved.relative_to(dlc.resolve()).as_posix() - except ValueError: - pass - - with _song_io_lock: - proposals, reason = _gap_fill_proposals(cache_key, resolved) - additions = {k: proposals[k] for k in _GAP_FILL_KEYS if k in keys and k in proposals} - skipped = sorted(set(keys) - set(additions)) - if not additions: - return JSONResponse({"error": "nothing to write", "reason": reason, - "skipped": skipped}, 409) - try: - import songmeta - songmeta.gap_fill_sloppak(resolved, additions) - except Exception: - log.warning("gap-fill write failed for %s", cache_key, exc_info=True) - return JSONResponse({"error": "write failed"}, 500) - - # Keep the cache row consistent with what the scanner would now derive - # (same contract as the metadata editor above): sync the columns the - # scan reads from the keys we appended, then re-stat so the row stays - # cache-fresh. - fields = {} - if "album" in additions: - fields["album"] = additions["album"] - if "year" in additions: - fields["year"] = str(additions["year"]) - if "genres" in additions: - fields["genre"] = additions["genres"][0] - with meta_db._lock: - updates = [f"{field} = ?" for field in fields] - params = list(fields.values()) - try: - mtime, size = _stat_for_cache(resolved) - updates += ["mtime = ?", "size = ?"] - params += [mtime, size] - except OSError: - pass - if updates: - params.append(cache_key) - meta_db.conn.execute( - f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params) - meta_db.conn.commit() - - _invalidate_song_caches(cache_key) - _kick_scan() - return {"ok": True, "written": additions, "skipped": skipped} @@ -4252,42 +3497,11 @@ def post_song_gap_fill(filename: str, data: dict): -@app.get("/api/song/{filename:path}") -async def get_song_info(filename: str): - """Return song metadata, from cache or by extracting it from the song source.""" - import asyncio - dlc = _get_dlc_dir() - if not dlc: - return JSONResponse({"error": "DLC folder not configured"}, 404) - song_path = _resolve_dlc_path(dlc, filename) - if song_path is None: - return JSONResponse({"error": "forbidden"}, 403) - if not song_path.exists(): - return JSONResponse({"error": "File not found"}, 404) - # Canonicalise the cache key against the resolved path so two URL - # forms of the same physical file (e.g. `Artist/song.sloppak` vs - # `Artist/../Artist/song.sloppak`) converge on a single row instead - # of fragmenting / shadowing each other in meta_db. - try: - cache_key = song_path.relative_to(dlc.resolve()).as_posix() - except ValueError: - cache_key = filename - mtime, size = _stat_for_cache(song_path) - cached = meta_db.get(cache_key, mtime, size) - if cached: - return cached - # Extract in thread pool - def _extract(): - meta = _extract_meta_for_file(song_path, dlc) - meta_db.put(cache_key, mtime, size, meta) - return meta - meta = await asyncio.get_event_loop().run_in_executor(None, _extract) - return meta # ── Highway WebSocket ─────────────────────────────────────────────────────────