mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
refactor(server): extract the playlists routes into routers/playlists.py (R3) (#841)
The biggest router yet — 12 playlist routes + custom covers — and the first that needs the config-path seam. server.py: 9,302 -> 9,085 (-217). Three causally-linked pieces, all required for playlists: - `config_dir` joins the appstate seam (the plan always put the path constants there; deferred in S3, needed now). It's env-derived, so the ~49 pop-and-reimport fixtures reconfigure it for free — ZERO setattr retargeting. STATIC_DIR/SLOPPAK_CACHE_DIR (patched via setattr) stay in server.py until a router that reads them is extracted, and get retargeted then. - `_clean_str` (pure request-field sanitizer, 14 callers) -> lib/reqfields.py; server.py imports it back. Unblocks wanted/saved/collections/profile/... later. - routers/playlists.py: bodies verbatim, `@app`->`@router`, `meta_db`-> `appstate.meta_db`, `CONFIG_DIR`->`appstate.config_dir`, `_clean_str` from reqfields, `_ART_CACHE_HEADERS` as a local const (art keeps server.py's). The two exclusive cover helpers (_playlist_cover_path/_url) move with it. include_router at the original site; full 143-route table identical to origin/main. One test retarget: test_playlists_api called `server._playlist_cover_path` directly -> now imports it from routers.playlists (reads appstate.config_dir, which the `server` fixture configures). Verified: pyflakes clean; route table identical; pytest 2401 passed (28 in playlists+collections+appstate); packaging guard 51 (auto-picked up reqfields); eslint 0; boot smoke drives create/rename/add-song/cover-upload/serve/delete — the cover writes 1.png under CONFIG_DIR THROUGH appstate.config_dir and serves 200 with an mtime cache-bust token; a wrong-typed name field still 400s via _clean_str; demo untouched. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4fd0cd49e7
commit
a883f9213f
+1
-1
@@ -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.
|
next root-level module can't ship broken.
|
||||||
|
|
||||||
### Added
|
### 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
|
- **`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`
|
||||||
(9,302 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
(9,085 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||||
extractions and the first three `routers/` modules) ·
|
extractions and four `routers/` modules) ·
|
||||||
`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`
|
||||||
|
|||||||
+10
-4
@@ -62,10 +62,16 @@ copies a hardcoded file list — that regression is what moved this file here.
|
|||||||
meta_db = None
|
meta_db = None
|
||||||
audio_effect_mappings = None
|
audio_effect_mappings = None
|
||||||
|
|
||||||
# Declared up front so `configure()` can reject a typo'd or stale keyword
|
# Config paths. server.py derives these from the environment (fresh on every
|
||||||
# instead of silently creating a new global that nothing ever reads. A seam
|
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
|
||||||
# whose wiring can no-op undetected is worse than no seam.
|
# here. Routers read them as `appstate.config_dir` etc. — a module attribute at
|
||||||
_SLOTS = frozenset({"meta_db", "audio_effect_mappings"})
|
# 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:
|
def configure(**kwargs) -> None:
|
||||||
|
|||||||
@@ -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 ""
|
||||||
@@ -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}
|
||||||
@@ -66,12 +66,13 @@ from metadata_db import (
|
|||||||
# The audio-effect routing index. Same shape as metadata_db: the class lives in
|
# 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.
|
# its own module, the `audio_effect_mappings` singleton below stays here.
|
||||||
from audio_effects_db import AudioEffectsMappingDB
|
from audio_effects_db import AudioEffectsMappingDB
|
||||||
|
from reqfields import _clean_str
|
||||||
# The router seam. Imported as a module (never `from appstate import ...`) so
|
# The router seam. Imported as a module (never `from appstate import ...`) so
|
||||||
# `appstate.configure(...)` below publishes into the same namespace routers read.
|
# `appstate.configure(...)` below publishes into the same namespace routers read.
|
||||||
# Lives in lib/ because that is the one core dir every packaging path copies.
|
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||||
import appstate
|
import appstate
|
||||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
# 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 sloppak as sloppak_mod
|
||||||
import drums as drums_mod
|
import drums as drums_mod
|
||||||
import notation as notation_mod
|
import notation as notation_mod
|
||||||
@@ -366,6 +367,7 @@ audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)
|
|||||||
appstate.configure(
|
appstate.configure(
|
||||||
meta_db=meta_db,
|
meta_db=meta_db,
|
||||||
audio_effect_mappings=audio_effect_mappings,
|
audio_effect_mappings=audio_effect_mappings,
|
||||||
|
config_dir=CONFIG_DIR,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4999,13 +5001,6 @@ def api_get_profile():
|
|||||||
return 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")
|
@app.post("/api/profile")
|
||||||
def api_set_profile(data: dict):
|
def api_set_profile(data: dict):
|
||||||
@@ -5588,222 +5583,10 @@ def api_song_stats(filename: str):
|
|||||||
return meta_db.get_song_stats(filename)
|
return meta_db.get_song_stats(filename)
|
||||||
|
|
||||||
|
|
||||||
# ── Playlists / Saved for Later / Continue-Playing (fee[dB]ack v0.3.0) ────────
|
# ── Playlists / custom covers (fee[dB]ack v0.3.0) ─────────────────────────────
|
||||||
|
# Mounted here, where these routes used to be defined (FastAPI matches in
|
||||||
def _playlist_cover_path(pid) -> Path | None:
|
# registration order). Implementation in lib/routers/playlists.py.
|
||||||
"""Filesystem path of a playlist's optional custom cover image (PNG),
|
app.include_router(playlists.router)
|
||||||
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) ───────────────────────────────
|
# ── Smart collections API (feedBack#636 item 2) ───────────────────────────────
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import sys
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
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()
|
@pytest.fixture()
|
||||||
def server(tmp_path, monkeypatch, isolate_logging):
|
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):
|
def test_deleting_playlist_removes_custom_cover(client, server):
|
||||||
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
|
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
|
||||||
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
|
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}")
|
client.delete(f"/api/playlists/{pid}")
|
||||||
assert not server._playlist_cover_path(pid).exists()
|
assert not _playlist_cover_path(pid).exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user