mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-24 22:02:09 +00:00
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>
165 lines
7.8 KiB
Python
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
|