mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 07:38:30 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b44116e85a | ||
|
|
9a58a55fe8 | ||
|
|
bbdff4e10f | ||
|
|
7258e1066a |
+1
-1
@@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
|
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
|
||||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `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
|
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
|
||||||
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
|
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
|
||||||
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
|||||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||||
|
|
||||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||||
(5,540 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
(3,692 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||||
extractions and sixteen `routers/` modules (the album-art routes are now `lib/routers/art.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
|
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
|
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||||
and is a monolith in its own right, to be split per-table once the router train
|
and is a monolith in its own right, to be split per-table once the router train
|
||||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ art_cache_dir = None
|
|||||||
song_pack_art_exists = None
|
song_pack_art_exists = None
|
||||||
art_override_paths = None
|
art_override_paths = None
|
||||||
art_safe_name = None
|
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({
|
_SLOTS = frozenset({
|
||||||
"meta_db", "audio_effect_mappings", "tuning_providers",
|
"meta_db", "audio_effect_mappings", "tuning_providers",
|
||||||
@@ -107,6 +118,8 @@ _SLOTS = frozenset({
|
|||||||
"get_progression_content", "builtin_diagnostic_filename",
|
"get_progression_content", "builtin_diagnostic_filename",
|
||||||
"running_version",
|
"running_version",
|
||||||
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
|
"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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
_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:
|
def _fpcalc_bin() -> str | None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.<callable>: 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
|
||||||
@@ -14,6 +14,7 @@ back-compat for `.sloppak` libraries or stop accepting the new `.feedpak`:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
from routers import settings as settings_router
|
||||||
import io
|
import io
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -242,7 +243,7 @@ def test_settings_dlc_count_includes_both_suffixes(tmp_path, settings_server):
|
|||||||
(dlc / "c.FEEDPAK").write_bytes(b"") # case-insensitive (suffix.lower())
|
(dlc / "c.FEEDPAK").write_bytes(b"") # case-insensitive (suffix.lower())
|
||||||
(dlc / "notes.txt").write_bytes(b"") # ignored
|
(dlc / "notes.txt").write_bytes(b"") # ignored
|
||||||
|
|
||||||
result = settings_server.save_settings({"dlc_dir": str(dlc)})
|
result = settings_router.save_settings({"dlc_dir": str(dlc)})
|
||||||
|
|
||||||
assert "error" not in result, result
|
assert "error" not in result, result
|
||||||
# save_settings joins its notices into a single ``message`` string.
|
# save_settings joins its notices into a single ``message`` string.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ shadow the config.json dlc_dir fallback.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
from routers import settings as settings_router
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -32,13 +33,13 @@ class _DirectSettingsClient:
|
|||||||
def get(self, path):
|
def get(self, path):
|
||||||
if path != "/api/settings":
|
if path != "/api/settings":
|
||||||
raise ValueError(f"unsupported path: {path}")
|
raise ValueError(f"unsupported path: {path}")
|
||||||
return _DirectResponse(self._server.get_settings())
|
return _DirectResponse(settings_router.get_settings())
|
||||||
|
|
||||||
def post(self, path, json):
|
def post(self, path, json):
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
return _DirectResponse(self._server.save_settings(json))
|
return _DirectResponse(settings_router.save_settings(json))
|
||||||
if path == "/api/settings/reset":
|
if path == "/api/settings/reset":
|
||||||
return _DirectResponse(self._server.reset_settings(json))
|
return _DirectResponse(settings_router.reset_settings(json))
|
||||||
raise ValueError(f"unsupported path: {path}")
|
raise ValueError(f"unsupported path: {path}")
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -643,7 +644,7 @@ def test_achievements_enabled_persists_and_validates(api_client, tmp_path):
|
|||||||
|
|
||||||
def test_achievements_enabled_is_resettable(server_module):
|
def test_achievements_enabled_is_resettable(server_module):
|
||||||
"""The flag is in the resettable allow-list so a Reset clears it to default."""
|
"""The flag is in the resettable allow-list so a Reset clears it to default."""
|
||||||
assert "achievements_enabled" in server_module._RESETTABLE_SETTINGS_KEYS
|
assert "achievements_enabled" in settings_router._RESETTABLE_SETTINGS_KEYS
|
||||||
|
|
||||||
|
|
||||||
def test_skip_startup_tasks_drives_startup_to_complete(api_client):
|
def test_skip_startup_tasks_drives_startup_to_complete(api_client):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ is exercised separately in `test_plugins.py`.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import importlib
|
import importlib
|
||||||
|
from routers import settings as settings_router
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -478,7 +479,7 @@ def test_normalize_export_paths_consistency(server_mod, tmp_path):
|
|||||||
# Wraps `_validate_relpath` to assert it doesn't raise the
|
# Wraps `_validate_relpath` to assert it doesn't raise the
|
||||||
# hard-failure ValueErrors. _UndeclaredFile would mean the
|
# hard-failure ValueErrors. _UndeclaredFile would mean the
|
||||||
# allowlist is wrong, not that the relpath shape is bad.
|
# allowlist is wrong, not that the relpath shape is bad.
|
||||||
server_mod._validate_relpath(rel, cleaned, tmp_path)
|
settings_router._validate_relpath(rel, cleaned, tmp_path)
|
||||||
|
|
||||||
|
|
||||||
# ── Atomic write: unique tmp + cleanup on failure ───────────────────────────
|
# ── Atomic write: unique tmp + cleanup on failure ───────────────────────────
|
||||||
@@ -502,7 +503,7 @@ def test_atomic_write_cleans_up_tmp_on_failure(server_mod, tmp_path, monkeypatch
|
|||||||
|
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
with pytest.raises(OSError):
|
with pytest.raises(OSError):
|
||||||
server_mod._atomic_write_file(target, b"payload")
|
settings_router._atomic_write_file(target, b"payload")
|
||||||
|
|
||||||
# Both attempts cleaned up. No .tmp.import residue means the
|
# Both attempts cleaned up. No .tmp.import residue means the
|
||||||
# mkstemp + finally-unlink pattern held even across failures.
|
# mkstemp + finally-unlink pattern held even across failures.
|
||||||
@@ -512,7 +513,7 @@ def test_atomic_write_cleans_up_tmp_on_failure(server_mod, tmp_path, monkeypatch
|
|||||||
|
|
||||||
# Restoring real replace, the function should still work end-to-end.
|
# Restoring real replace, the function should still work end-to-end.
|
||||||
monkeypatch.setattr(server_mod.os, "replace", real_replace)
|
monkeypatch.setattr(server_mod.os, "replace", real_replace)
|
||||||
server_mod._atomic_write_file(target, b"payload")
|
settings_router._atomic_write_file(target, b"payload")
|
||||||
assert target.read_bytes() == b"payload"
|
assert target.read_bytes() == b"payload"
|
||||||
assert list(tmp_path.glob("*.tmp.import")) == []
|
assert list(tmp_path.glob("*.tmp.import")) == []
|
||||||
|
|
||||||
@@ -764,7 +765,7 @@ def test_atomic_write_closes_fd_when_fdopen_fails(server_mod, tmp_path, monkeypa
|
|||||||
monkeypatch.setattr(server_mod.os, "fdopen", boom_fdopen)
|
monkeypatch.setattr(server_mod.os, "fdopen", boom_fdopen)
|
||||||
|
|
||||||
with pytest.raises(OSError, match="simulated EMFILE"):
|
with pytest.raises(OSError, match="simulated EMFILE"):
|
||||||
server_mod._atomic_write_file(target, b"payload")
|
settings_router._atomic_write_file(target, b"payload")
|
||||||
|
|
||||||
# fd was closed (so it didn't leak), and the temp file mkstemp
|
# fd was closed (so it didn't leak), and the temp file mkstemp
|
||||||
# created was removed (so it doesn't litter / lock on Windows).
|
# created was removed (so it doesn't litter / lock on Windows).
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ this file pins the additive `core_server_files` section:
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import importlib
|
import importlib
|
||||||
|
from routers import settings as settings_router
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -118,7 +119,7 @@ def test_import_stages_db_restore_without_touching_live_db(client, server_mod, t
|
|||||||
|
|
||||||
payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored")
|
payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored")
|
||||||
r = client.post("/api/settings/import", json={
|
r = client.post("/api/settings/import", json={
|
||||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||||
"server_config": {},
|
"server_config": {},
|
||||||
"core_server_files": {
|
"core_server_files": {
|
||||||
"web_library.db": {"encoding": "base64",
|
"web_library.db": {"encoding": "base64",
|
||||||
@@ -143,7 +144,7 @@ def test_import_rejects_corrupt_db_with_valid_magic_header(client, server_mod, t
|
|||||||
# fail to open the bad restore.
|
# fail to open the bad restore.
|
||||||
corrupt = b"SQLite format 3\x00" + b"\xff" * 200
|
corrupt = b"SQLite format 3\x00" + b"\xff" * 200
|
||||||
r = client.post("/api/settings/import", json={
|
r = client.post("/api/settings/import", json={
|
||||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||||
"server_config": {},
|
"server_config": {},
|
||||||
"core_server_files": {
|
"core_server_files": {
|
||||||
"web_library.db": {"encoding": "base64",
|
"web_library.db": {"encoding": "base64",
|
||||||
@@ -158,7 +159,7 @@ def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
|
|||||||
# A truncated / wrong file staged as the restore would brick startup —
|
# A truncated / wrong file staged as the restore would brick startup —
|
||||||
# reject anything lacking the SQLite magic header, before touching disk.
|
# reject anything lacking the SQLite magic header, before touching disk.
|
||||||
r = client.post("/api/settings/import", json={
|
r = client.post("/api/settings/import", json={
|
||||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||||
"server_config": {},
|
"server_config": {},
|
||||||
"core_server_files": {
|
"core_server_files": {
|
||||||
"web_library.db": {"encoding": "base64",
|
"web_library.db": {"encoding": "base64",
|
||||||
@@ -171,7 +172,7 @@ def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
|
|||||||
|
|
||||||
def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
|
def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
|
||||||
r = client.post("/api/settings/import", json={
|
r = client.post("/api/settings/import", json={
|
||||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||||
"server_config": {},
|
"server_config": {},
|
||||||
"core_server_files": {
|
"core_server_files": {
|
||||||
"playlist_covers/7.png": {"encoding": "base64",
|
"playlist_covers/7.png": {"encoding": "base64",
|
||||||
@@ -186,7 +187,7 @@ def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
|
|||||||
def test_import_core_path_traversal_rejected(client, server_mod, tmp_path):
|
def test_import_core_path_traversal_rejected(client, server_mod, tmp_path):
|
||||||
secret = tmp_path.parent / "escape.txt"
|
secret = tmp_path.parent / "escape.txt"
|
||||||
r = client.post("/api/settings/import", json={
|
r = client.post("/api/settings/import", json={
|
||||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||||
"server_config": {},
|
"server_config": {},
|
||||||
"core_server_files": {
|
"core_server_files": {
|
||||||
"../escape.txt": {"encoding": "base64",
|
"../escape.txt": {"encoding": "base64",
|
||||||
@@ -201,7 +202,7 @@ def test_import_core_undeclared_path_skipped_not_fatal(client, server_mod, tmp_p
|
|||||||
# A relpath outside the core allowlist is a warn-and-skip, not a refusal —
|
# A relpath outside the core allowlist is a warn-and-skip, not a refusal —
|
||||||
# the rest of the bundle still applies.
|
# the rest of the bundle still applies.
|
||||||
r = client.post("/api/settings/import", json={
|
r = client.post("/api/settings/import", json={
|
||||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||||
"server_config": {},
|
"server_config": {},
|
||||||
"core_server_files": {
|
"core_server_files": {
|
||||||
"audio_cache/x.ogg": {"encoding": "base64",
|
"audio_cache/x.ogg": {"encoding": "base64",
|
||||||
@@ -289,7 +290,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
|
|||||||
def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch):
|
def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch):
|
||||||
# A backup that silently omits the library DB is a data-loss trap — the
|
# A backup that silently omits the library DB is a data-loss trap — the
|
||||||
# export must error rather than hand back an incomplete-looking bundle.
|
# export must error rather than hand back an incomplete-looking bundle.
|
||||||
monkeypatch.setattr(server_mod, "_snapshot_library_db", lambda: None)
|
monkeypatch.setattr(settings_router, "_snapshot_library_db", lambda: None)
|
||||||
r = client.get("/api/settings/export")
|
r = client.get("/api/settings/export")
|
||||||
assert r.status_code == 500
|
assert r.status_code == 500
|
||||||
assert "library database" in r.json()["error"].lower()
|
assert "library database" in r.json()["error"].lower()
|
||||||
@@ -299,16 +300,16 @@ def test_failed_import_disarms_staged_db_restore(client, server_mod, tmp_path, m
|
|||||||
# If a later write in phase 2 fails, the request 500s — but a staged DB
|
# If a later write in phase 2 fails, the request 500s — but a staged DB
|
||||||
# restore must NOT survive to swap in on the next restart.
|
# restore must NOT survive to swap in on the next restart.
|
||||||
payload = _valid_db_bytes(tmp_path, name="incoming.db")
|
payload = _valid_db_bytes(tmp_path, name="incoming.db")
|
||||||
real_write = server_mod._atomic_write_file
|
real_write = settings_router._atomic_write_file
|
||||||
|
|
||||||
def boom(target, data):
|
def boom(target, data):
|
||||||
if target.name == "config.json": # last write of the commit
|
if target.name == "config.json": # last write of the commit
|
||||||
raise OSError("disk full")
|
raise OSError("disk full")
|
||||||
return real_write(target, data)
|
return real_write(target, data)
|
||||||
|
|
||||||
monkeypatch.setattr(server_mod, "_atomic_write_file", boom)
|
monkeypatch.setattr(settings_router, "_atomic_write_file", boom)
|
||||||
r = client.post("/api/settings/import", json={
|
r = client.post("/api/settings/import", json={
|
||||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||||
"server_config": {},
|
"server_config": {},
|
||||||
"core_server_files": {
|
"core_server_files": {
|
||||||
"web_library.db": {"encoding": "base64",
|
"web_library.db": {"encoding": "base64",
|
||||||
|
|||||||
Reference in New Issue
Block a user