diff --git a/CHANGELOG.md b/CHANGELOG.md index 55fc46f3..ef67b71b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 — saved A/B practice regions). 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 + 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 diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 9adda18a..b2a5126b 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(9,302 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and the first three `routers/` modules) · +(9,085 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and four `routers/` modules) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/lib/appstate.py b/lib/appstate.py index d515673a..78dcecf8 100644 --- a/lib/appstate.py +++ b/lib/appstate.py @@ -62,10 +62,16 @@ def artist_page(name): meta_db = None audio_effect_mappings = None -# 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"}) +# 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"}) def configure(**kwargs) -> None: diff --git a/lib/reqfields.py b/lib/reqfields.py new file mode 100644 index 00000000..6cac8240 --- /dev/null +++ b/lib/reqfields.py @@ -0,0 +1,13 @@ +"""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 "" diff --git a/lib/routers/playlists.py b/lib/routers/playlists.py new file mode 100644 index 00000000..4d8adafa --- /dev/null +++ b/lib/routers/playlists.py @@ -0,0 +1,237 @@ +"""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``. +""" + +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import FileResponse, JSONResponse + +import appstate +from reqfields import _clean_str + +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) + 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)} + + +@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} diff --git a/server.py b/server.py index 816b6624..42db67b5 100644 --- a/server.py +++ b/server.py @@ -66,12 +66,13 @@ # 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 +from routers import audio_effects, artist_aliases, loops, playlists import sloppak as sloppak_mod import drums as drums_mod import notation as notation_mod @@ -366,6 +367,7 @@ def _env_flag(name: str) -> bool: appstate.configure( meta_db=meta_db, audio_effect_mappings=audio_effect_mappings, + config_dir=CONFIG_DIR, ) @@ -4999,13 +5001,6 @@ 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): @@ -5588,222 +5583,10 @@ def api_song_stats(filename: str): return meta_db.get_song_stats(filename) -# ── 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} +# ── Playlists / custom covers (fee[dB]ack v0.3.0) ───────────────────────────── +# Mounted here, where these routes used to be defined (FastAPI matches in +# registration order). Implementation in lib/routers/playlists.py. +app.include_router(playlists.router) # ── Smart collections API (feedBack#636 item 2) ─────────────────────────────── diff --git a/tests/test_playlists_api.py b/tests/test_playlists_api.py index 85487464..8b9b308f 100644 --- a/tests/test_playlists_api.py +++ b/tests/test_playlists_api.py @@ -6,6 +6,10 @@ 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): @@ -168,6 +172,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 server._playlist_cover_path(pid).exists() + assert _playlist_cover_path(pid).exists() client.delete(f"/api/playlists/{pid}") - assert not server._playlist_cover_path(pid).exists() + assert not _playlist_cover_path(pid).exists()