mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 16:30:09 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f21b27196f | ||
|
|
73a654f293 |
@@ -5,6 +5,10 @@
|
||||
!dockerfile
|
||||
!requirements.txt
|
||||
!server.py
|
||||
# The router seam server.py injects its singletons into (R3). Root-level Python
|
||||
# that ships in the image must be re-allowed explicitly — this file starts with
|
||||
# a blanket `*` exclusion. `routers/` needs the same treatment when it lands.
|
||||
!appstate.py
|
||||
!main.py
|
||||
!VERSION
|
||||
!tailwind.config.js
|
||||
|
||||
+4
-32
@@ -7,38 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
||||
`static/`, `plugins/__init__.py`. The root-level `appstate.py` and `routers/` added in
|
||||
R3 shipped correctly in Docker and passed every test, and were then silently dropped
|
||||
from the packaged app, which died at startup. Both now live under **`lib/`** — the one
|
||||
core directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
|
||||
bundler (`cp -r lib`) all copy wholesale, and that all three put on `sys.path` (on
|
||||
Windows via the embeddable-Python `._pth`, where `PYTHONPATH` is ignored). This needs no
|
||||
change in feedback-desktop and no new release to take effect. Placing them there is also
|
||||
correct under Principle V: with the injection seam, `appstate.py` constructs nothing and
|
||||
does no import-time IO, and a route module only builds an `APIRouter`. The
|
||||
`Dockerfile` / `.dockerignore` / `docker-compose.yml` entries added for the root layout
|
||||
are reverted. New `tests/test_packaging.py` walks `server.py`'s module-level imports and
|
||||
fails if any first-party module resolves outside a directory the packagers copy, so the
|
||||
next root-level module can't ship broken.
|
||||
|
||||
### Added
|
||||
- **`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 + custom covers). The `config_dir` path constant now rides 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
|
||||
where they used to be defined** — FastAPI matches routes in registration order, so the
|
||||
mount site preserves it. Verified: the full 143-route table (paths, methods, *and*
|
||||
order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are
|
||||
the decorator receiver (`@app.get` → `@router.get`) and the singleton read
|
||||
(`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute
|
||||
resolved at call time). This proves the seam from #833 under a real consumer, including
|
||||
the second slot. The `_demo_mode_guard` middleware still blocks all four moved write
|
||||
routes with 403, and `Query(...)` validation still 422s — both checked against a running
|
||||
server. `server.py`: **9,445 → 9,386 lines**.
|
||||
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
|
||||
need `meta_db` and friends but must not `import server`, or the import graph goes
|
||||
circular the moment `server` imports them back. So `server.py` keeps *constructing*
|
||||
@@ -56,7 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap
|
||||
as ES `import`. `configure()` rejects an unknown slot rather than silently creating a
|
||||
global nothing reads, and the suite asserts `server` actually calls it (a seam whose
|
||||
wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`.
|
||||
wiring can no-op undetected is worse than no seam). Ships in the image via
|
||||
`Dockerfile` `COPY appstate.py /app/` plus a `.dockerignore` allowlist entry — that
|
||||
file opens with a blanket `*` exclusion, so root-level Python must be re-allowed
|
||||
explicitly or the build fails.
|
||||
|
||||
### Changed
|
||||
- **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py`
|
||||
|
||||
@@ -206,6 +206,9 @@ COPY --from=tailwind-builder /build/static/tailwind.min.css /app/static/tailwind
|
||||
# when a plugin is installed at runtime (see update_manager on-install hook).
|
||||
COPY tailwind.config.js /app/tailwind.config.js
|
||||
COPY server.py /app/
|
||||
# The router seam server.py injects its singletons into (R3). Root-level, like
|
||||
# server.py, so `import appstate` resolves off PYTHONPATH=/app.
|
||||
COPY appstate.py /app/
|
||||
COPY main.py /app/
|
||||
COPY VERSION /app/
|
||||
# Built-in diagnostic sloppaks seeded into DLC_DIR/diagnostics-builtin/ at scan
|
||||
|
||||
@@ -47,31 +47,16 @@ quietly operating on a stand-in.
|
||||
|
||||
Slots are added here only when a router actually needs one — this is a seam,
|
||||
not a grab-bag for everything in ``server.py``.
|
||||
|
||||
**Why this lives in ``lib/`` and not the repo root.** Because it constructs
|
||||
nothing and does no import-time IO, it satisfies Principle V's rule for ``lib/``
|
||||
modules — and ``lib/`` is the only core directory every packaging path already
|
||||
copies: the Dockerfile (``COPY lib/``), ``docker-compose.yml``, and
|
||||
feedback-desktop's ``bundle-slopsmith.sh`` (``cp -r lib``). All three also put
|
||||
both the bundle root and ``lib/`` on ``sys.path``. A root-level module ships in
|
||||
Docker but is silently dropped from the packaged desktop app, whose bundler
|
||||
copies a hardcoded file list — that regression is what moved this file here.
|
||||
"""
|
||||
|
||||
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
|
||||
meta_db = None
|
||||
audio_effect_mappings = None
|
||||
|
||||
# Config paths. server.py derives these from the environment (fresh on every
|
||||
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
|
||||
# here. Routers read them as `appstate.config_dir` etc. — a module attribute at
|
||||
# call time. NOTE: config_dir/dlc_dir are env-derived, so a `setenv`+reimport
|
||||
# test reconfigures them for free; STATIC_DIR/SLOPPAK_CACHE_DIR are patched via
|
||||
# `setattr(server, …)` in a few tests, so those slots (when added) need their
|
||||
# tests retargeted to appstate in the same PR.
|
||||
config_dir = None
|
||||
|
||||
_SLOTS = frozenset({"meta_db", "audio_effect_mappings", "config_dir"})
|
||||
# Declared up front so `configure()` can reject a typo'd or stale keyword
|
||||
# instead of silently creating a new global that nothing ever reads. A seam
|
||||
# whose wiring can no-op undetected is worse than no seam.
|
||||
_SLOTS = frozenset({"meta_db", "audio_effect_mappings"})
|
||||
|
||||
|
||||
def configure(**kwargs) -> None:
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
# Mount source for live reload during development
|
||||
- ./static:/app/static
|
||||
- ./server.py:/app/server.py
|
||||
- ./appstate.py:/app/appstate.py
|
||||
- ./VERSION:/app/VERSION
|
||||
- ./ug_browser.py:/app/ug_browser.py
|
||||
- ./lib:/app/lib
|
||||
|
||||
@@ -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`
|
||||
(9,085 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and four `routers/` modules) ·
|
||||
(9,445 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions, plus 12 lines for the `appstate.py` seam) ·
|
||||
`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`
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Request-field coercion helpers shared by the raw-`dict` POST handlers.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). Pure — no IO, no globals — so it
|
||||
imports cleanly from both ``server`` and any ``routers/`` module.
|
||||
"""
|
||||
|
||||
|
||||
def _clean_str(value) -> str:
|
||||
"""Trim a request field to a string; non-strings (or missing) → ''.
|
||||
Lets the raw-`dict` POST handlers treat wrong-typed JSON (an int/list/etc.
|
||||
where a string was expected) as "empty" and answer 400, instead of raising
|
||||
AttributeError/TypeError → 500 on a later .strip()/`in`."""
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
@@ -1,31 +0,0 @@
|
||||
"""FastAPI route modules extracted from ``server.py`` (R3).
|
||||
|
||||
Each module here exposes a module-level ``router`` (a ``fastapi.APIRouter``)
|
||||
that ``server.py`` mounts with ``app.include_router(...)`` at the point in the
|
||||
file where those routes used to be defined — FastAPI matches routes in
|
||||
registration order, so keeping the mount site preserves it.
|
||||
|
||||
**Routers must never ``import server``.** They reach core singletons through
|
||||
the injected seam instead::
|
||||
|
||||
import appstate
|
||||
|
||||
@router.get("/api/thing")
|
||||
def get_thing():
|
||||
return appstate.meta_db.thing()
|
||||
|
||||
and always as a **module attribute, at call time** — never
|
||||
``from appstate import meta_db``, which freezes the binding and defeats both a
|
||||
later ``appstate.configure()`` and ``monkeypatch.setattr``. See ``appstate.py``.
|
||||
|
||||
Dependencies flow one way: ``server -> routers -> appstate``.
|
||||
|
||||
**Why this lives under ``lib/``.** ``lib/`` is the only core directory every
|
||||
packaging path already copies wholesale — the Dockerfile (``COPY lib/``),
|
||||
``docker-compose.yml``, and feedback-desktop's ``bundle-slopsmith.sh``
|
||||
(``cp -r lib``) — and all three put it on ``sys.path``. A root-level package
|
||||
ships in Docker but is silently dropped from the packaged desktop app, whose
|
||||
bundler copies a hardcoded file list. Route modules import nothing at module
|
||||
scope beyond FastAPI and ``appstate``, so they do no import-time IO and satisfy
|
||||
Principle V's rule for ``lib/``.
|
||||
"""
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Artist aliases / Tidy-up (P4) — canonicalize messy artist tags at DISPLAY
|
||||
("ACDC" -> "AC/DC") without touching feedpak files or the scanner-derived
|
||||
songs.artist. All DB-only.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton read (``meta_db`` ->
|
||||
``appstate.meta_db``) changed. The read stays a module attribute so a re-imported
|
||||
``server`` re-publishes a fresh DB into the seam — see ``appstate.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/artist-aliases")
|
||||
def list_artist_aliases():
|
||||
"""Existing raw→canonical overrides (the Tidy-up 'current merges' list)."""
|
||||
return {"aliases": appstate.meta_db.list_artist_aliases()}
|
||||
|
||||
|
||||
@router.get("/api/artists/raw")
|
||||
def list_raw_artists(limit: int = 2000):
|
||||
"""Distinct RAW artist names + song counts + current canonical — the Tidy-up
|
||||
picker (you merge raw variants into one canonical)."""
|
||||
return {"artists": appstate.meta_db.raw_artists(limit)}
|
||||
|
||||
|
||||
@router.post("/api/artist-aliases")
|
||||
def set_artist_alias(data: dict):
|
||||
"""Upsert one override: {raw_name, canonical_name, mb_artist_id?}. A self-alias
|
||||
(raw == canonical) clears the row instead (un-merge)."""
|
||||
raw = (data.get("raw_name") or "").strip()
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
if not raw or not canon:
|
||||
return JSONResponse({"error": "raw_name and canonical_name are required"}, 400)
|
||||
result = appstate.meta_db.set_artist_alias(raw, canon, (data.get("mb_artist_id") or None))
|
||||
if not result.get("ok"):
|
||||
# Would form a cycle (raw → … → raw) — refuse rather than corrupt the chain.
|
||||
return JSONResponse(
|
||||
{"error": "alias would create a cycle", "raw_name": raw, "canonical_name": canon},
|
||||
409)
|
||||
return {"ok": True, "raw_name": raw, "canonical_name": result.get("canonical_name", canon)}
|
||||
|
||||
|
||||
@router.post("/api/artist-aliases/merge")
|
||||
def merge_artist_aliases(data: dict):
|
||||
"""Merge several raw artist variants into one canonical:
|
||||
{raw_names: [...], canonical_name}. The canonical's own self-alias is skipped.
|
||||
Returns {merged: N}."""
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
raws = data.get("raw_names")
|
||||
if not canon:
|
||||
return JSONResponse({"error": "canonical_name is required"}, 400)
|
||||
if not isinstance(raws, list) or not raws:
|
||||
return JSONResponse({"error": "raw_names must be a non-empty array"}, 400)
|
||||
n = appstate.meta_db.merge_artists(raws, canon)
|
||||
return {"merged": n, "canonical_name": canon}
|
||||
|
||||
|
||||
@router.delete("/api/artist-aliases/{raw_name:path}")
|
||||
def delete_artist_alias(raw_name: str):
|
||||
"""Remove one override so that raw artist stands on its own again."""
|
||||
appstate.meta_db.remove_artist_alias(raw_name)
|
||||
return {"ok": True}
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Audio-effects mapping API — the core-owned song/tone -> provider routing index.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton read (``audio_effect_mappings`` ->
|
||||
``appstate.audio_effect_mappings``) changed. The read must stay a module
|
||||
attribute so a re-imported ``server`` re-publishes a fresh DB into the seam and
|
||||
`monkeypatch.setattr` reaches this module — see ``appstate.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _audio_effects_error(exc: Exception):
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
|
||||
@router.get("/api/audio-effects/mappings")
|
||||
def list_audio_effect_mappings(
|
||||
song_key: str = Query(""),
|
||||
filename: str = Query(""),
|
||||
tone_key: str = Query(""),
|
||||
provider_id: str = Query(""),
|
||||
):
|
||||
try:
|
||||
return {
|
||||
"mappings": appstate.audio_effect_mappings.list(
|
||||
song_key=song_key,
|
||||
filename=filename,
|
||||
tone_key=tone_key,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
}
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
|
||||
|
||||
@router.post("/api/audio-effects/mappings")
|
||||
def upsert_audio_effect_mapping(data: dict = Body(...)):
|
||||
try:
|
||||
mapping = appstate.audio_effect_mappings.upsert(data)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@router.delete("/api/audio-effects/mappings/{mapping_id}")
|
||||
def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")):
|
||||
try:
|
||||
deleted = appstate.audio_effect_mappings.delete(mapping_id, provider_id=provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not deleted:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/audio-effects/mappings/{mapping_id}/activate")
|
||||
def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)):
|
||||
try:
|
||||
provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId")
|
||||
mapping = appstate.audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not mapping:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@router.delete("/api/audio-effects/active-mapping")
|
||||
def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")):
|
||||
try:
|
||||
cleared = appstate.audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "cleared": cleared}
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Practice loops — saved A/B regions per song.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton reads (``meta_db`` ->
|
||||
``appstate.meta_db``) changed. See ``appstate.py`` for why the reads stay
|
||||
module attributes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/loops")
|
||||
def list_loops(filename: str):
|
||||
# Hold the DB lock for the read: the shared single connection
|
||||
# (check_same_thread=False) is serialized through meta_db._lock by every
|
||||
# writer, so an unlocked SELECT here can overlap a POST/DELETE commit.
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
rows = db.conn.execute(
|
||||
"SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time",
|
||||
(filename,)
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "start": r[2], "end": r[3]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/loops")
|
||||
def save_loop(data: dict):
|
||||
filename = data.get("filename", "")
|
||||
name = data.get("name", "").strip()
|
||||
start = data.get("start")
|
||||
end = data.get("end")
|
||||
if not filename or start is None or end is None:
|
||||
return {"error": "Missing fields"}
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
# COUNT + INSERT under one lock so two unnamed POSTs can't read the same
|
||||
# count and both mint "Loop N" (the count is only used to name the row).
|
||||
if not name:
|
||||
count = db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,)
|
||||
).fetchone()[0]
|
||||
name = f"Loop {count + 1}"
|
||||
db.conn.execute(
|
||||
"INSERT INTO loops (filename, name, start_time, end_time) VALUES (?, ?, ?, ?)",
|
||||
(filename, name, float(start), float(end))
|
||||
)
|
||||
db.conn.commit()
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
@router.delete("/api/loops/{loop_id}")
|
||||
def delete_loop(loop_id: int):
|
||||
with appstate.meta_db._lock:
|
||||
appstate.meta_db.conn.execute("DELETE FROM loops WHERE id = ?", (loop_id,))
|
||||
appstate.meta_db.conn.commit()
|
||||
return {"ok": True}
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Playlists + custom playlist covers (fee[dB]ack v0.3.0).
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). Edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR`` -> ``appstate.config_dir``
|
||||
(both read at call time through the seam), and ``_clean_str`` now imports from
|
||||
``reqfields``. See ``appstate.py``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Cache policy for the custom-cover file response: revalidate every time so a
|
||||
# replaced cover is never served stale (pairs with the mtime-ns URL token).
|
||||
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
def _playlist_cover_path(pid) -> Path | None:
|
||||
"""Filesystem path of a playlist's optional custom cover image (PNG),
|
||||
stored under CONFIG_DIR. Returns None for a non-integer id."""
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return appstate.config_dir / "playlist_covers" / f"{pid}.png"
|
||||
|
||||
|
||||
def _playlist_cover_url(pid) -> str | None:
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return None
|
||||
try:
|
||||
# Nanosecond mtime so a same-second replace/remove/re-upload still
|
||||
# changes the cache-bust token (int seconds could collide → stale image).
|
||||
mt = cover.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mt = 0
|
||||
return f"/api/playlists/{pid}/cover?v={mt}"
|
||||
|
||||
|
||||
@router.get("/api/playlists")
|
||||
def api_list_playlists():
|
||||
lists = appstate.meta_db.list_playlists()
|
||||
for pl in lists:
|
||||
pl["cover_url"] = _playlist_cover_url(pl["id"])
|
||||
return lists
|
||||
|
||||
|
||||
@router.post("/api/playlists")
|
||||
def api_create_playlist(data: dict):
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
# kind='album' = a curated album (§7.2): hand-picked works, a chosen chart
|
||||
# per slot, played front-to-back on the queue. Absent/None = a regular mix.
|
||||
kind = _clean_str(data.get("kind")) or None
|
||||
if kind not in (None, "album"):
|
||||
return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400)
|
||||
return appstate.meta_db.create_playlist(name, kind=kind)
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}")
|
||||
def api_get_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl["cover_url"] = _playlist_cover_url(pid)
|
||||
return pl
|
||||
|
||||
|
||||
@router.patch("/api/playlists/{pid}")
|
||||
def api_rename_playlist(pid: int, data: dict):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be renamed."}, status_code=400)
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
appstate.meta_db.rename_playlist(pid, name)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}")
|
||||
def api_delete_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
|
||||
if not appstate.meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/songs")
|
||||
def api_add_playlist_song(pid: int, data: dict):
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
if appstate.meta_db.add_playlist_song(pid, filename) is None: # playlist vanished under us
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.patch("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_update_playlist_slot(pid: int, filename: str, data: dict):
|
||||
"""Edit one curated-album slot: {"arrangement": name|null} pins/clears the
|
||||
slot's arrangement; {"chart_filename": fn} swaps the slot to another chart
|
||||
of the same work (position + pin kept). Albums only — a mix has no slots."""
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl.get("kind") != "album":
|
||||
return JSONResponse({"error": "Slot editing is for albums."}, status_code=400)
|
||||
kwargs = {}
|
||||
if "chart_filename" in data:
|
||||
new_fn = _clean_str(data.get("chart_filename"))
|
||||
if not new_fn:
|
||||
return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400)
|
||||
kwargs["new_filename"] = new_fn
|
||||
if "arrangement" in data:
|
||||
arr = data.get("arrangement")
|
||||
if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100):
|
||||
return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400)
|
||||
kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None
|
||||
if not kwargs:
|
||||
return JSONResponse({"error": "nothing to update"}, status_code=400)
|
||||
if appstate.meta_db.update_playlist_slot(pid, filename, **kwargs) is None:
|
||||
return JSONResponse(
|
||||
{"error": "no such slot, or the chart isn't a version of this song"},
|
||||
status_code=400)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_remove_playlist_song(pid: int, filename: str):
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
appstate.meta_db.remove_playlist_song(pid, filename)
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/reorder")
|
||||
def api_reorder_playlist(pid: int, data: dict):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
order = data.get("order")
|
||||
if not isinstance(order, list) or not all(isinstance(f, str) for f in order):
|
||||
return JSONResponse({"error": "order must be a list of filenames"}, status_code=400)
|
||||
# Require an exact permutation of the playlist's current songs: a list with
|
||||
# duplicates, omissions, or extras would otherwise produce duplicate
|
||||
# positions / a partial reorder while still returning 200.
|
||||
current = [s["filename"] for s in pl["songs"]]
|
||||
if len(order) != len(current) or sorted(order) != sorted(current):
|
||||
return JSONResponse(
|
||||
{"error": "order must be a permutation of the playlist's current songs"},
|
||||
status_code=400,
|
||||
)
|
||||
appstate.meta_db.reorder_playlist(pid, order)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/cover")
|
||||
async def api_set_playlist_cover(pid: int, data: dict):
|
||||
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
|
||||
Overrides the content-dependent (song-art) cover. Stored as a small PNG
|
||||
thumbnail under CONFIG_DIR/playlist_covers/."""
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
# Guard the type before the `","` membership test — a non-string image
|
||||
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
|
||||
# Mirrors the avatar/song-art upload guard.
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
if not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
cover = _playlist_cover_path(pid)
|
||||
cover.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Decode/validate the image — a bad payload is a CLIENT error (400), and the
|
||||
# message stays generic so it can't echo internals.
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
||||
img.thumbnail((640, 640)) # covers stay small
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid image"}, status_code=400)
|
||||
# Persist. A save/replace failure is a SERVER error (500, logged, no
|
||||
# filesystem detail leaked) — the pre-split handler mislabeled these as 400
|
||||
# and echoed the exception. A unique temp name in the cover dir (not a shared
|
||||
# `{pid}.png.tmp`) means two concurrent uploads can't clobber each other's
|
||||
# temp file; the atomic replace publishes. Re-check the playlist still exists
|
||||
# just before publishing so a delete that raced the decode above can't leave
|
||||
# an orphan cover — cheap belt-and-braces; FeedBack is single-user
|
||||
# (Principle I), so a full per-playlist lock would be for a race the
|
||||
# deployment model precludes.
|
||||
tmp = None
|
||||
try:
|
||||
# mkstemp is inside the try too: an unwritable dir / full disk raises
|
||||
# here, and that's the same class of persistence failure as save/replace.
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f".{pid}.", suffix=".png.tmp", dir=str(cover.parent))
|
||||
tmp = Path(tmp_name)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
img.save(f, "PNG")
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
tmp.replace(cover)
|
||||
except Exception:
|
||||
if tmp is not None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
log.exception("playlist cover save failed (pid=%s)", pid)
|
||||
return JSONResponse({"error": "could not save cover"}, status_code=500)
|
||||
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}/cover")
|
||||
def api_get_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
# no-cache (revalidate) like song art, so a replaced cover is never served
|
||||
# stale — pairs with the mtime-ns cache-bust token on the URL.
|
||||
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}/cover")
|
||||
def api_delete_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
@@ -21,7 +21,7 @@ configure_logging()
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException
|
||||
from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Query
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
|
||||
@@ -66,13 +66,9 @@ from metadata_db import (
|
||||
# The audio-effect routing index. Same shape as metadata_db: the class lives in
|
||||
# its own module, the `audio_effect_mappings` singleton below stays here.
|
||||
from audio_effects_db import AudioEffectsMappingDB
|
||||
from reqfields import _clean_str
|
||||
# The router seam. Imported as a module (never `from appstate import ...`) so
|
||||
# `appstate.configure(...)` below publishes into the same namespace routers read.
|
||||
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||
import appstate
|
||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||
from routers import audio_effects, artist_aliases, loops, playlists
|
||||
import sloppak as sloppak_mod
|
||||
import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
@@ -367,7 +363,6 @@ audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)
|
||||
appstate.configure(
|
||||
meta_db=meta_db,
|
||||
audio_effect_mappings=audio_effect_mappings,
|
||||
config_dir=CONFIG_DIR,
|
||||
)
|
||||
|
||||
|
||||
@@ -4849,9 +4844,59 @@ def list_tags():
|
||||
|
||||
|
||||
# ── Artist aliases / Tidy-up (P4) ────────────────────────────────────────────
|
||||
# Mounted here, where these routes used to be defined (FastAPI matches in
|
||||
# registration order). Implementation in lib/routers/artist_aliases.py.
|
||||
app.include_router(artist_aliases.router)
|
||||
# Canonicalize messy artist tags at DISPLAY ("ACDC" → "AC/DC") without touching
|
||||
# the feedpak files or the scanner-derived songs.artist. All DB-only.
|
||||
|
||||
@app.get("/api/artist-aliases")
|
||||
def list_artist_aliases():
|
||||
"""Existing raw→canonical overrides (the Tidy-up 'current merges' list)."""
|
||||
return {"aliases": meta_db.list_artist_aliases()}
|
||||
|
||||
|
||||
@app.get("/api/artists/raw")
|
||||
def list_raw_artists(limit: int = 2000):
|
||||
"""Distinct RAW artist names + song counts + current canonical — the Tidy-up
|
||||
picker (you merge raw variants into one canonical)."""
|
||||
return {"artists": meta_db.raw_artists(limit)}
|
||||
|
||||
|
||||
@app.post("/api/artist-aliases")
|
||||
def set_artist_alias(data: dict):
|
||||
"""Upsert one override: {raw_name, canonical_name, mb_artist_id?}. A self-alias
|
||||
(raw == canonical) clears the row instead (un-merge)."""
|
||||
raw = (data.get("raw_name") or "").strip()
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
if not raw or not canon:
|
||||
return JSONResponse({"error": "raw_name and canonical_name are required"}, 400)
|
||||
result = meta_db.set_artist_alias(raw, canon, (data.get("mb_artist_id") or None))
|
||||
if not result.get("ok"):
|
||||
# Would form a cycle (raw → … → raw) — refuse rather than corrupt the chain.
|
||||
return JSONResponse(
|
||||
{"error": "alias would create a cycle", "raw_name": raw, "canonical_name": canon},
|
||||
409)
|
||||
return {"ok": True, "raw_name": raw, "canonical_name": result.get("canonical_name", canon)}
|
||||
|
||||
|
||||
@app.post("/api/artist-aliases/merge")
|
||||
def merge_artist_aliases(data: dict):
|
||||
"""Merge several raw artist variants into one canonical:
|
||||
{raw_names: [...], canonical_name}. The canonical's own self-alias is skipped.
|
||||
Returns {merged: N}."""
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
raws = data.get("raw_names")
|
||||
if not canon:
|
||||
return JSONResponse({"error": "canonical_name is required"}, 400)
|
||||
if not isinstance(raws, list) or not raws:
|
||||
return JSONResponse({"error": "raw_names must be a non-empty array"}, 400)
|
||||
n = meta_db.merge_artists(raws, canon)
|
||||
return {"merged": n, "canonical_name": canon}
|
||||
|
||||
|
||||
@app.delete("/api/artist-aliases/{raw_name:path}")
|
||||
def delete_artist_alias(raw_name: str):
|
||||
"""Remove one override so that raw artist stands on its own again."""
|
||||
meta_db.remove_artist_alias(raw_name)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Artist pages (launch charrette PR-B) ──────────────────────────────────────
|
||||
@@ -5001,6 +5046,13 @@ def api_get_profile():
|
||||
return profile
|
||||
|
||||
|
||||
def _clean_str(value) -> str:
|
||||
"""Trim a request field to a string; non-strings (or missing) → ''.
|
||||
Lets the raw-`dict` POST handlers treat wrong-typed JSON (an int/list/etc.
|
||||
where a string was expected) as "empty" and answer 400, instead of raising
|
||||
AttributeError/TypeError → 500 on a later .strip()/`in`."""
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
@app.post("/api/profile")
|
||||
def api_set_profile(data: dict):
|
||||
@@ -5583,10 +5635,222 @@ def api_song_stats(filename: str):
|
||||
return meta_db.get_song_stats(filename)
|
||||
|
||||
|
||||
# ── Playlists / custom covers (fee[dB]ack v0.3.0) ─────────────────────────────
|
||||
# Mounted here, where these routes used to be defined (FastAPI matches in
|
||||
# registration order). Implementation in lib/routers/playlists.py.
|
||||
app.include_router(playlists.router)
|
||||
# ── Playlists / Saved for Later / Continue-Playing (fee[dB]ack v0.3.0) ────────
|
||||
|
||||
def _playlist_cover_path(pid) -> Path | None:
|
||||
"""Filesystem path of a playlist's optional custom cover image (PNG),
|
||||
stored under CONFIG_DIR. Returns None for a non-integer id."""
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return CONFIG_DIR / "playlist_covers" / f"{pid}.png"
|
||||
|
||||
|
||||
def _playlist_cover_url(pid) -> str | None:
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return None
|
||||
try:
|
||||
# Nanosecond mtime so a same-second replace/remove/re-upload still
|
||||
# changes the cache-bust token (int seconds could collide → stale image).
|
||||
mt = cover.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mt = 0
|
||||
return f"/api/playlists/{pid}/cover?v={mt}"
|
||||
|
||||
|
||||
@app.get("/api/playlists")
|
||||
def api_list_playlists():
|
||||
lists = meta_db.list_playlists()
|
||||
for pl in lists:
|
||||
pl["cover_url"] = _playlist_cover_url(pl["id"])
|
||||
return lists
|
||||
|
||||
|
||||
@app.post("/api/playlists")
|
||||
def api_create_playlist(data: dict):
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
# kind='album' = a curated album (§7.2): hand-picked works, a chosen chart
|
||||
# per slot, played front-to-back on the queue. Absent/None = a regular mix.
|
||||
kind = _clean_str(data.get("kind")) or None
|
||||
if kind not in (None, "album"):
|
||||
return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400)
|
||||
return meta_db.create_playlist(name, kind=kind)
|
||||
|
||||
|
||||
@app.get("/api/playlists/{pid}")
|
||||
def api_get_playlist(pid: int):
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl["cover_url"] = _playlist_cover_url(pid)
|
||||
return pl
|
||||
|
||||
|
||||
@app.patch("/api/playlists/{pid}")
|
||||
def api_rename_playlist(pid: int, data: dict):
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be renamed."}, status_code=400)
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
meta_db.rename_playlist(pid, name)
|
||||
return meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@app.delete("/api/playlists/{pid}")
|
||||
def api_delete_playlist(pid: int):
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
|
||||
if not meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/playlists/{pid}/songs")
|
||||
def api_add_playlist_song(pid: int, data: dict):
|
||||
if meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
if meta_db.add_playlist_song(pid, filename) is None: # playlist vanished under us
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl = meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@app.patch("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_update_playlist_slot(pid: int, filename: str, data: dict):
|
||||
"""Edit one curated-album slot: {"arrangement": name|null} pins/clears the
|
||||
slot's arrangement; {"chart_filename": fn} swaps the slot to another chart
|
||||
of the same work (position + pin kept). Albums only — a mix has no slots."""
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl.get("kind") != "album":
|
||||
return JSONResponse({"error": "Slot editing is for albums."}, status_code=400)
|
||||
kwargs = {}
|
||||
if "chart_filename" in data:
|
||||
new_fn = _clean_str(data.get("chart_filename"))
|
||||
if not new_fn:
|
||||
return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400)
|
||||
kwargs["new_filename"] = new_fn
|
||||
if "arrangement" in data:
|
||||
arr = data.get("arrangement")
|
||||
if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100):
|
||||
return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400)
|
||||
kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None
|
||||
if not kwargs:
|
||||
return JSONResponse({"error": "nothing to update"}, status_code=400)
|
||||
if meta_db.update_playlist_slot(pid, filename, **kwargs) is None:
|
||||
return JSONResponse(
|
||||
{"error": "no such slot, or the chart isn't a version of this song"},
|
||||
status_code=400)
|
||||
return meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@app.delete("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_remove_playlist_song(pid: int, filename: str):
|
||||
if meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
meta_db.remove_playlist_song(pid, filename)
|
||||
pl = meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@app.post("/api/playlists/{pid}/reorder")
|
||||
def api_reorder_playlist(pid: int, data: dict):
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
order = data.get("order")
|
||||
if not isinstance(order, list) or not all(isinstance(f, str) for f in order):
|
||||
return JSONResponse({"error": "order must be a list of filenames"}, status_code=400)
|
||||
# Require an exact permutation of the playlist's current songs: a list with
|
||||
# duplicates, omissions, or extras would otherwise produce duplicate
|
||||
# positions / a partial reorder while still returning 200.
|
||||
current = [s["filename"] for s in pl["songs"]]
|
||||
if len(order) != len(current) or sorted(order) != sorted(current):
|
||||
return JSONResponse(
|
||||
{"error": "order must be a permutation of the playlist's current songs"},
|
||||
status_code=400,
|
||||
)
|
||||
meta_db.reorder_playlist(pid, order)
|
||||
return meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@app.post("/api/playlists/{pid}/cover")
|
||||
async def api_set_playlist_cover(pid: int, data: dict):
|
||||
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
|
||||
Overrides the content-dependent (song-art) cover. Stored as a small PNG
|
||||
thumbnail under CONFIG_DIR/playlist_covers/."""
|
||||
if meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
# Guard the type before the `","` membership test — a non-string image
|
||||
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
|
||||
# Mirrors the avatar/song-art upload guard.
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
if not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
cover = _playlist_cover_path(pid)
|
||||
cover.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
||||
img.thumbnail((640, 640)) # covers stay small
|
||||
tmp = cover.with_suffix(".png.tmp")
|
||||
img.save(str(tmp), "PNG")
|
||||
tmp.replace(cover)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
|
||||
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
|
||||
|
||||
|
||||
@app.get("/api/playlists/{pid}/cover")
|
||||
def api_get_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
# no-cache (revalidate) like song art, so a replaced cover is never served
|
||||
# stale — pairs with the mtime-ns cache-bust token on the URL.
|
||||
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@app.delete("/api/playlists/{pid}/cover")
|
||||
def api_delete_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Smart collections API (feedBack#636 item 2) ───────────────────────────────
|
||||
@@ -5686,15 +5950,111 @@ def api_remove_wanted(wanted_id: int):
|
||||
|
||||
|
||||
# ── Loops API ────────────────────────────────────────────────────────────────
|
||||
# Mounted here, where these routes used to be defined (FastAPI matches in
|
||||
# registration order). Implementation in lib/routers/loops.py.
|
||||
app.include_router(loops.router)
|
||||
|
||||
@app.get("/api/loops")
|
||||
def list_loops(filename: str):
|
||||
rows = meta_db.conn.execute(
|
||||
"SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time",
|
||||
(filename,)
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "start": r[2], "end": r[3]} for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/loops")
|
||||
def save_loop(data: dict):
|
||||
filename = data.get("filename", "")
|
||||
name = data.get("name", "").strip()
|
||||
start = data.get("start")
|
||||
end = data.get("end")
|
||||
if not filename or start is None or end is None:
|
||||
return {"error": "Missing fields"}
|
||||
if not name:
|
||||
count = meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,)
|
||||
).fetchone()[0]
|
||||
name = f"Loop {count + 1}"
|
||||
with meta_db._lock:
|
||||
meta_db.conn.execute(
|
||||
"INSERT INTO loops (filename, name, start_time, end_time) VALUES (?, ?, ?, ?)",
|
||||
(filename, name, float(start), float(end))
|
||||
)
|
||||
meta_db.conn.commit()
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
@app.delete("/api/loops/{loop_id}")
|
||||
def delete_loop(loop_id: int):
|
||||
with meta_db._lock:
|
||||
meta_db.conn.execute("DELETE FROM loops WHERE id = ?", (loop_id,))
|
||||
meta_db.conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Audio Effects Mapping API ───────────────────────────────────────────────
|
||||
# Mounted here, where these routes used to be defined: FastAPI matches in
|
||||
# registration order, so the mount site preserves it.
|
||||
app.include_router(audio_effects.router)
|
||||
|
||||
def _audio_effects_error(exc: Exception):
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
|
||||
@app.get("/api/audio-effects/mappings")
|
||||
def list_audio_effect_mappings(
|
||||
song_key: str = Query(""),
|
||||
filename: str = Query(""),
|
||||
tone_key: str = Query(""),
|
||||
provider_id: str = Query(""),
|
||||
):
|
||||
try:
|
||||
return {
|
||||
"mappings": audio_effect_mappings.list(
|
||||
song_key=song_key,
|
||||
filename=filename,
|
||||
tone_key=tone_key,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
}
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
|
||||
|
||||
@app.post("/api/audio-effects/mappings")
|
||||
def upsert_audio_effect_mapping(data: dict = Body(...)):
|
||||
try:
|
||||
mapping = audio_effect_mappings.upsert(data)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@app.delete("/api/audio-effects/mappings/{mapping_id}")
|
||||
def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")):
|
||||
try:
|
||||
deleted = audio_effect_mappings.delete(mapping_id, provider_id=provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not deleted:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/audio-effects/mappings/{mapping_id}/activate")
|
||||
def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)):
|
||||
try:
|
||||
provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId")
|
||||
mapping = audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not mapping:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@app.delete("/api/audio-effects/active-mapping")
|
||||
def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")):
|
||||
try:
|
||||
cleared = audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "cleared": cleared}
|
||||
|
||||
|
||||
# ── Settings API ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Concurrent unnamed loop saves must get unique names.
|
||||
|
||||
`save_loop` auto-names an unnamed loop `Loop {count+1}`. If the `COUNT(*)` runs
|
||||
outside the DB lock (as it did before the fix), two simultaneous unnamed POSTs
|
||||
read the same count and both mint the same name. A `threading.Barrier` releases
|
||||
all workers into `save_loop` at once to force that interleave.
|
||||
|
||||
Single-user app, so this race is unlikely in practice — but the fix is one lock
|
||||
scope, and the test pins it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB
|
||||
from routers import loops
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def meta_db(tmp_path):
|
||||
prev = appstate.meta_db
|
||||
db = MetadataDB(tmp_path)
|
||||
appstate.configure(meta_db=db)
|
||||
yield db
|
||||
db.conn.close()
|
||||
appstate.configure(meta_db=prev)
|
||||
|
||||
|
||||
def test_concurrent_unnamed_saves_get_unique_names(meta_db):
|
||||
workers = 16
|
||||
barrier = threading.Barrier(workers)
|
||||
names, errors = [], []
|
||||
lock = threading.Lock()
|
||||
|
||||
def save():
|
||||
try:
|
||||
barrier.wait() # release all at once
|
||||
r = loops.save_loop({"filename": "song.feedpak", "start": 0.0, "end": 1.0})
|
||||
with lock:
|
||||
names.append(r["name"])
|
||||
except Exception as e: # noqa: BLE001 — surface any thread error
|
||||
with lock:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=save) for _ in range(workers)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, errors
|
||||
assert len(names) == workers
|
||||
# The load-bearing assertion: no two auto-named loops collide.
|
||||
assert len(set(names)) == workers, f"duplicate loop names: {sorted(names)}"
|
||||
# And the DB agrees — every insert landed.
|
||||
stored = meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", ("song.feedpak",)
|
||||
).fetchone()[0]
|
||||
assert stored == workers
|
||||
|
||||
|
||||
def test_list_and_save_do_not_error_under_interleave(meta_db):
|
||||
"""A read overlapping writes must not raise (shared connection, one lock)."""
|
||||
stop = threading.Event()
|
||||
errors = []
|
||||
|
||||
def reader():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
loops.list_loops("song.feedpak")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(e)
|
||||
|
||||
r = threading.Thread(target=reader)
|
||||
r.start()
|
||||
try:
|
||||
for i in range(40):
|
||||
loops.save_loop({"filename": "song.feedpak", "name": f"n{i}", "start": 0.0, "end": 1.0})
|
||||
finally:
|
||||
stop.set()
|
||||
r.join()
|
||||
assert not errors, errors
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Guard: every first-party module `server.py` imports must be one the packagers copy.
|
||||
|
||||
feedback-desktop's `scripts/bundle-slopsmith.sh` copies a **hardcoded list** from
|
||||
core into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`, `static/`,
|
||||
`plugins/__init__.py`. A new root-level module (say `appstate.py`) ships fine in
|
||||
Docker, imports fine under pytest, and is then *silently dropped* from the
|
||||
packaged desktop app, which dies at startup with:
|
||||
|
||||
File ".../Resources/slopsmith/server.py", line 71, in <module>
|
||||
import appstate
|
||||
ModuleNotFoundError: No module named 'appstate'
|
||||
|
||||
That shipped once. This test is why it can't ship twice: it walks `server.py`'s
|
||||
module-level imports, keeps the ones that resolve inside this repo, and asserts
|
||||
each lives under a directory every packaging path already copies wholesale.
|
||||
|
||||
If you add a first-party module for `server.py`, put it in `lib/` — the one core
|
||||
directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
|
||||
bundler (`cp -r lib`) all copy, and that all three put on `sys.path`. If you
|
||||
genuinely need it at the repo root, you must also teach `bundle-slopsmith.sh`,
|
||||
the `Dockerfile`, `.dockerignore`, and `docker-compose.yml` about it — and then
|
||||
update `BUNDLED_ROOTS` below.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
# Directories every packaging path copies wholesale, plus the files copied by name.
|
||||
BUNDLED_ROOTS = ("lib", "plugins", "data", "static")
|
||||
BUNDLED_FILES = ("server.py", "main.py")
|
||||
|
||||
|
||||
def _server_toplevel_imports():
|
||||
"""Module names imported at `server.py`'s top level (not inside a function)."""
|
||||
tree = ast.parse((REPO_ROOT / "server.py").read_text())
|
||||
names = set()
|
||||
for node in tree.body: # top level only — lazy imports are fine
|
||||
if isinstance(node, ast.Import):
|
||||
names.update(a.name.split(".")[0] for a in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||
names.add(node.module.split(".")[0])
|
||||
return sorted(names)
|
||||
|
||||
|
||||
# `spec.origin` is not always a path: built-in and frozen stdlib modules use
|
||||
# these sentinels. `Path("frozen").resolve()` would land inside the repo and
|
||||
# report `os` as first-party — so filter them before touching the filesystem.
|
||||
_NON_PATH_ORIGINS = {"built-in", "frozen", "namespace"}
|
||||
|
||||
|
||||
def _first_party_origin(name):
|
||||
"""Path of `name` if it resolves inside this repo, else None (stdlib/site-package)."""
|
||||
try:
|
||||
spec = importlib.util.find_spec(name)
|
||||
except (ImportError, ValueError):
|
||||
return None
|
||||
if spec is None:
|
||||
return None
|
||||
|
||||
if spec.origin and spec.origin not in _NON_PATH_ORIGINS:
|
||||
origin = pathlib.Path(spec.origin)
|
||||
else:
|
||||
# Namespace/frozen: fall back to the first search location, if any.
|
||||
locations = list(getattr(spec, "submodule_search_locations", None) or [])
|
||||
if not locations:
|
||||
return None
|
||||
origin = pathlib.Path(locations[0])
|
||||
|
||||
if not origin.is_absolute():
|
||||
return None # a sentinel, not a real path
|
||||
origin = origin.resolve()
|
||||
try:
|
||||
origin.relative_to(REPO_ROOT)
|
||||
except ValueError:
|
||||
return None # outside the repo → a dependency
|
||||
return origin
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _server_toplevel_imports())
|
||||
def test_server_import_is_bundled(name):
|
||||
origin = _first_party_origin(name)
|
||||
if origin is None:
|
||||
return # stdlib or an installed dependency
|
||||
|
||||
rel = origin.relative_to(REPO_ROOT)
|
||||
if rel.as_posix() in BUNDLED_FILES or rel.parts[0] in BUNDLED_ROOTS:
|
||||
return
|
||||
|
||||
raise AssertionError(
|
||||
f"server.py imports `{name}` from {rel}, which no packager copies.\n"
|
||||
f"The desktop bundler (scripts/bundle-slopsmith.sh) copies only "
|
||||
f"{BUNDLED_FILES} and {BUNDLED_ROOTS}/, so the packaged app would die "
|
||||
f"at startup with ModuleNotFoundError: No module named '{name}'.\n"
|
||||
f"Move it under lib/, or teach bundle-slopsmith.sh + Dockerfile + "
|
||||
f".dockerignore + docker-compose.yml about it and update BUNDLED_ROOTS."
|
||||
)
|
||||
|
||||
|
||||
def test_the_seam_and_routers_live_under_lib():
|
||||
"""Pin the two that already caused a shipped break."""
|
||||
for name in ("appstate", "routers"):
|
||||
origin = _first_party_origin(name)
|
||||
assert origin is not None, f"{name} does not resolve inside the repo"
|
||||
assert origin.relative_to(REPO_ROOT).parts[0] == "lib", (
|
||||
f"{name} resolved to {origin.relative_to(REPO_ROOT)}; it must live "
|
||||
f"under lib/ or the packaged desktop app will not ship it"
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Playlist cover upload: client vs server errors, no temp litter, no leak.
|
||||
|
||||
The pre-split handler caught decode AND persistence failures in one `except`,
|
||||
returned 400 for both, and echoed the exception (`Invalid image: {e}`) — so a
|
||||
disk/permission failure was mislabeled as a client error and could leak a
|
||||
filesystem path. These pin the split.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB
|
||||
from routers import playlists
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path):
|
||||
prev = (appstate.meta_db, appstate.config_dir)
|
||||
db = MetadataDB(tmp_path)
|
||||
appstate.configure(meta_db=db, config_dir=tmp_path)
|
||||
app_ = __import__("fastapi").FastAPI()
|
||||
app_.include_router(playlists.router)
|
||||
try:
|
||||
yield TestClient(app_), tmp_path
|
||||
finally:
|
||||
db.conn.close()
|
||||
appstate.configure(meta_db=prev[0], config_dir=prev[1])
|
||||
|
||||
|
||||
def _png_b64():
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (10, 20, 30)).save(buf, "PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def _make_playlist(client):
|
||||
return client.post("/api/playlists", json={"name": "P"}).json()["id"]
|
||||
|
||||
|
||||
def test_valid_cover_saves_and_leaves_no_temp(client):
|
||||
c, tmp_path = client
|
||||
pid = _make_playlist(c)
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
|
||||
assert r.status_code == 200
|
||||
cover_dir = tmp_path / "playlist_covers"
|
||||
assert (cover_dir / f"{pid}.png").exists()
|
||||
# The atomic-publish temp file must not linger.
|
||||
assert not list(cover_dir.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_undecodable_image_is_a_400_without_leaking(client):
|
||||
c, _ = client
|
||||
pid = _make_playlist(c)
|
||||
# valid base64, not a valid image
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": base64.b64encode(b"not an image").decode()})
|
||||
assert r.status_code == 400
|
||||
body = r.json()["error"]
|
||||
assert body == "Invalid image" # generic — no exception detail echoed
|
||||
assert "playlist_covers" not in body # no filesystem path leak
|
||||
|
||||
|
||||
def test_save_failure_is_a_500_not_a_400(client, monkeypatch):
|
||||
"""A persistence failure (here: Image.save raising) must be a logged 500,
|
||||
not a 400 — the whole point of the decode/persist split. Negative-checks
|
||||
against the pre-fix behavior, which returned 400 for exactly this."""
|
||||
c, tmp_path = client
|
||||
pid = _make_playlist(c)
|
||||
payload = _png_b64() # build BEFORE patching save
|
||||
|
||||
def boom(self, fp, *a, **k):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(Image.Image, "save", boom)
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
|
||||
|
||||
assert r.status_code == 500
|
||||
assert "disk full" not in r.json()["error"] # no internal detail
|
||||
assert not list((tmp_path / "playlist_covers").glob("*.tmp")) # temp cleaned up
|
||||
|
||||
|
||||
def test_temp_creation_failure_is_a_500(client, monkeypatch):
|
||||
"""mkstemp raising (unwritable dir / full disk) must hit the same logged 500
|
||||
path as a save failure, not escape as an unhandled server error."""
|
||||
import tempfile as _tempfile
|
||||
|
||||
c, _ = client
|
||||
pid = _make_playlist(c)
|
||||
payload = _png_b64()
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("read-only file system")
|
||||
|
||||
monkeypatch.setattr(_tempfile, "mkstemp", boom)
|
||||
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
|
||||
assert r.status_code == 500
|
||||
assert "read-only" not in r.json()["error"]
|
||||
|
||||
|
||||
def test_upload_to_missing_playlist_is_404(client):
|
||||
c, _ = client
|
||||
r = c.post("/api/playlists/9999/cover", json={"image": _png_b64()})
|
||||
assert r.status_code == 404
|
||||
@@ -6,10 +6,6 @@ import sys
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Moved to routers/playlists in R3; reads appstate.config_dir, which the
|
||||
# `server` fixture configures via CONFIG_DIR before this is called.
|
||||
from routers.playlists import _playlist_cover_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
@@ -172,6 +168,6 @@ def test_cover_rejects_non_string_image_with_400_not_500(client):
|
||||
def test_deleting_playlist_removes_custom_cover(client, server):
|
||||
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
|
||||
assert _playlist_cover_path(pid).exists()
|
||||
assert server._playlist_cover_path(pid).exists()
|
||||
client.delete(f"/api/playlists/{pid}")
|
||||
assert not _playlist_cover_path(pid).exists()
|
||||
assert not server._playlist_cover_path(pid).exists()
|
||||
|
||||
Reference in New Issue
Block a user