feedBack/tests/test_collections_api.py
Byron Gamatos 5ed6f454e7
feat(library): smart collections as a library provider (#641)
Implements feedBack#636 item 2 (P1) — saved library filters that stay live,
the homelab primitive FeedBack was missing (Plex smart collections / Navidrome
.nsp / *arr custom filters).

A collection is a saved /api/library query surfaced as a registered library
provider, so it appears in the v3 source picker and inherits the whole Songs
UI (paging, stats, A–Z rail, art) with no new screen.

- Storage reuses the playlist subsystem: a `playlists.rules` JSON column
  (additive, idempotent migration). A row with rules != NULL is a smart
  collection; list_playlists + get_playlist filter `rules IS NULL`, so
  collections are excluded from the manual-playlist list and read-only to
  every playlist mutation that gates on get_playlist.
- SmartCollectionProvider (kind="local" — matched songs are local rows, so the
  client's play/art paths stay on the local branch) delegates query_page/
  query_stats/query_artists to the local DB with the stored rules applied;
  tuning_names/get_art delegate straight through. Registered via a boot scan +
  on create/update (replace=True) / delete.
- Rules mirror the raw /api/library query params; `_sanitize_collection_rules`
  drops unknown keys and is applied at API ingress AND on provider load, so a
  hand-edited / imported bad value can't crash a query.
- API: GET/POST/PUT/DELETE /api/collections. Frontend: a "+ Save as
  collection" action in the v3 filter drawer (local provider + active filters
  only) that names the current filter set and switches to it.

Reviewed by Codex; 3 findings fixed (local-kind playback path, save gated to
local provider, re-sanitize persisted rules).

Tests: tests/test_collections_api.py (CRUD, provider filtering, restart
re-registration, kind=local, corrupt-rule tolerance, playlist isolation),
tests/js/v3_collections.test.js.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:42:41 +02:00

165 lines
7.8 KiB
Python

"""Tests for smart/dynamic collections (got-feedback/feedBack#636 item 2).
A collection is a saved set of library filter rules, surfaced as a registered
library provider so it inherits the v3 Songs UI. Storage reuses the playlists
table (a `rules` JSON blob → smart collection); membership is the LIVE filter
result, not stored songs.
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _put(server_mod, *, filename, title, artist, tuning_name="E Standard", tuning_sort_key=0):
server_mod.meta_db.put(filename, 1.0, 1, {
"title": title, "artist": artist, "album": "LP", "year": "", "duration": 1.0,
"tuning": tuning_name, "arrangements": [], "has_lyrics": False, "format": "archive",
"stem_count": 0, "stem_ids": [], "tuning_name": tuning_name,
"tuning_sort_key": tuning_sort_key, "tuning_offsets": "",
})
def _seed_mixed(server_mod):
_put(server_mod, filename="d1.archive", title="Drop One", artist="Anna", tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="d2.archive", title="Drop Two", artist="Bea", tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="e1.archive", title="Std One", artist="Cy", tuning_name="E Standard")
# ── CRUD ────────────────────────────────────────────────────────────────────
def test_create_list_delete_collection(client):
assert client.get("/api/collections").json() == {"collections": []}
r = client.post("/api/collections", json={"name": "Drop D stuff", "rules": {"tunings": ["Drop D"]}})
assert r.status_code == 200
col = r.json()["collection"]
assert col["name"] == "Drop D stuff"
assert col["rules"] == {"tunings": "Drop D"} # raw query-param format
cid = col["id"]
listed = client.get("/api/collections").json()["collections"]
assert [c["name"] for c in listed] == ["Drop D stuff"]
assert client.request("DELETE", f"/api/collections/{cid}").json() == {"ok": True}
assert client.get("/api/collections").json() == {"collections": []}
def test_create_requires_name_and_sanitizes_rules(client):
assert client.post("/api/collections", json={"rules": {}}).status_code == 400
# Unknown rule keys are dropped (never 500); known ones normalized to the
# raw query-param format (list→CSV, favorites→1).
col = client.post("/api/collections", json={
"name": "Mix", "rules": {"tunings": ["Drop D", "Eb Standard"], "sort": "title", "bogus": "x", "favorites": True},
}).json()["collection"]
assert col["rules"] == {"tunings": "Drop D,Eb Standard", "sort": "title", "favorites": 1}
def test_update_collection(client):
cid = client.post("/api/collections", json={"name": "A", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
r = client.put(f"/api/collections/{cid}", json={"name": "B", "rules": {"format": "sloppak"}})
assert r.status_code == 200
assert r.json()["collection"]["name"] == "B"
assert r.json()["collection"]["rules"] == {"format": "sloppak"}
assert client.put("/api/collections/99999", json={"name": "x"}).status_code == 404
# ── Provider behaviour ──────────────────────────────────────────────────────
def test_collection_registers_as_a_provider(client, server_mod):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
providers = client.get("/api/library/providers").json()["providers"]
ids = [p["id"] for p in providers]
assert f"collection:{cid}" in ids
def test_collection_provider_returns_only_matching_songs(client, server_mod):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
pid = f"collection:{cid}"
page = client.get("/api/library", params={"provider": pid}).json()
titles = sorted(s["title"] for s in page["songs"])
assert titles == ["Drop One", "Drop Two"] # E Standard song excluded
stats = client.get("/api/library/stats", params={"provider": pid}).json()
assert stats["total_songs"] == 2
def test_collection_provider_is_local_kind(client, server_mod):
# kind="local" keeps the client's play/art paths on the local branch (a
# collection's matched songs are local rows), not the remote-sync branch.
cid = client.post("/api/collections", json={"name": "C", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
prov = next(p for p in client.get("/api/library/providers").json()["providers"]
if p["id"] == f"collection:{cid}")
assert prov["kind"] == "local"
def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
# A hand-edited / imported bad rules row (int where a string is expected, a
# list for `sort`) must not crash the query — the provider re-sanitizes on
# load. Write the bad JSON straight past the API sanitizer.
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "Bad", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
# `artist: []` (list for a string field) and `sort: []` (unhashable) are
# the values that would crash `.strip()` / `sort_map.get` if they reached a
# query — they must be dropped, leaving the valid `tunings` rule intact.
server_mod.meta_db.conn.execute(
"UPDATE playlists SET rules = ? WHERE id = ?",
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
)
server_mod.meta_db.conn.commit()
server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
assert r.status_code == 200 # no 500/503 from bad rules
assert sorted(s["title"] for s in r.json()["songs"]) == ["Drop One", "Drop Two"]
def test_collection_provider_survives_restart(client, server_mod, tmp_path, monkeypatch):
_seed_mixed(server_mod)
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
server_mod.meta_db.conn.close()
# Re-import the server (same CONFIG_DIR) → boot scan must re-register it.
sys.modules.pop("server", None)
mod2 = importlib.import_module("server")
try:
ids = [p["id"] for p in mod2.library_providers.list()]
assert f"collection:{cid}" in ids
finally:
mod2.meta_db.conn.close()
# ── Isolation from manual playlists ─────────────────────────────────────────
def test_collections_excluded_from_playlists_and_are_read_only(client):
cid = client.post("/api/collections", json={"name": "Coll", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
# Not listed among manual playlists...
assert all(p["id"] != cid for p in client.get("/api/playlists").json())
# ...and manual-playlist mutations 404 on a collection id (get_playlist gate).
assert client.post(f"/api/playlists/{cid}/songs", json={"filename": "d1.archive"}).status_code == 404
assert client.get(f"/api/playlists/{cid}").status_code == 404