mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-23 21:32:33 +00:00
* feat(v3 library): A–Z fast-scroll jump rail on the Songs grid Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar so you can jump the library to a starting letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between letters. The classic (v2) tree already had letter selection; this brings the new v3 grid to parity (it was the gap behind the "alphabetical scroll selection next to the scrollbar" idea). It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only offers letters present in the current sort AND filter set, so a tap always lands on a real card (absent letters are dimmed + non-interactive). The grid is forward-only, server-paged infinite scroll with no virtualization, so a jump pages through to the target card then scrolls to it; a token guards overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized window is the scaling follow-up for very large libraries. Backend: /api/library/stats gains an optional `sort` param and an additive `sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist or title), filter-synced — so the rail's present-letters match the grid's real order. The legacy `letters` (distinct-artist) field is unchanged, so the dashboard + classic tree are unaffected. `sort` is dropped for providers whose query_stats predates it (existing kwarg-filter), so third-party library providers keep working (rail simply falls back / hides). Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag + keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble). Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs- distinct-artist counting), tests/test_library_providers.py (sort forwarded), tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3 library): harden A–Z jump rail (review P2/P3) Addresses the PR #634 review findings (manual + Codex): P2 correctness - refreshRail prefers the active-sort `sort_letters`; falls back to the artist-based `letters` only on an artist sort, and hides the rail on a title sort when a legacy provider returns none (was mislabeling letters). - reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a grid that's being rebuilt from page 0. - songBucket no longer trims, matching the server SQL + grid ORDER BY raw first-char bucketing (a leading-space title now buckets under '#' on both sides). P3 polish - Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a magic 4000, keeping large libraries reachable while still bounded. - Roving tabindex: only the first present letter is tabbable; arrow keys move it. Removes up to 27 page tab stops. - `sort_letters` is computed only when the caller opts in (want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree skip the extra GROUP BY. Added sort + want_sort_letters to the optional provider-kwargs so non-introspectable legacy providers drop them. - _railToken supersedes stale refreshRail responses; hide the rail when no letters are present instead of rendering disabled buttons. Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
302 lines
11 KiB
Python
302 lines
11 KiB
Python
import importlib
|
|
import sys
|
|
|
|
import pytest
|
|
from fastapi.responses import Response
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture()
|
|
def server_mod(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
|
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="local.archive", title="Local Song", artist="Local Artist"):
|
|
server_mod.meta_db.put(filename, 1.0, 1, {
|
|
"title": title,
|
|
"artist": artist,
|
|
"album": "Local Album",
|
|
"year": "2026",
|
|
"duration": 180.0,
|
|
"tuning": "E Standard",
|
|
"arrangements": [{"index": 0, "name": "Lead", "notes": 1}],
|
|
"has_lyrics": True,
|
|
"format": "archive",
|
|
"stem_count": 0,
|
|
"stem_ids": [],
|
|
"tuning_name": "E Standard",
|
|
"tuning_sort_key": 0,
|
|
})
|
|
|
|
|
|
class FakeLibraryProvider:
|
|
id = "remote:frodo"
|
|
label = "Frodo's Library"
|
|
kind = "remote"
|
|
capabilities = ("library.read", "art.read", "song.sync")
|
|
|
|
def __init__(self):
|
|
self.page_kwargs = None
|
|
self.artist_kwargs = None
|
|
self.stats_kwargs = None
|
|
self.art_song_id = None
|
|
self.sync_song_id = None
|
|
|
|
def query_page(self, **kwargs):
|
|
self.page_kwargs = kwargs
|
|
return ([{
|
|
"filename": "remote-song-id",
|
|
"title": "Remote Song",
|
|
"artist": "Remote Artist",
|
|
"album": "Remote Album",
|
|
"arrangements": [],
|
|
"format": "archive",
|
|
}], 1)
|
|
|
|
def query_artists(self, **kwargs):
|
|
self.artist_kwargs = kwargs
|
|
return ([{
|
|
"name": "Remote Artist",
|
|
"album_count": 1,
|
|
"song_count": 1,
|
|
"albums": [{"name": "Remote Album", "songs": []}],
|
|
}], 1)
|
|
|
|
def query_stats(self, **kwargs):
|
|
self.stats_kwargs = kwargs
|
|
return {"total_songs": 1, "total_artists": 1, "letters": {"R": 1}}
|
|
|
|
def tuning_names(self):
|
|
return {"tunings": [{"name": "E Standard", "sort_key": 0, "count": 1}]}
|
|
|
|
def get_art(self, song_id: str):
|
|
self.art_song_id = song_id
|
|
return Response(content=b"fake-art", media_type="image/png")
|
|
|
|
def sync_song(self, song_id: str):
|
|
self.sync_song_id = song_id
|
|
return {"ok": True, "filename": "synced.archive", "song_id": song_id}
|
|
|
|
|
|
class ReadOnlyLibraryProvider:
|
|
id = "remote:readonly"
|
|
label = "Readonly Library"
|
|
kind = "remote"
|
|
capabilities = ("library.read",)
|
|
|
|
def query_page(self, **kwargs):
|
|
return ([], 0)
|
|
|
|
def query_artists(self, **kwargs):
|
|
return ([], 0)
|
|
|
|
def query_stats(self, **kwargs):
|
|
return {"total_songs": 0, "total_artists": 0, "letters": {}}
|
|
|
|
def tuning_names(self):
|
|
return {"tunings": []}
|
|
|
|
def get_art(self, song_id: str):
|
|
raise AssertionError("provider without art.read capability should not be dispatched")
|
|
|
|
def sync_song(self, song_id: str):
|
|
raise AssertionError("provider without song.sync capability should not be dispatched")
|
|
|
|
|
|
class NonBrowsableLibraryProvider:
|
|
id = "remote:actions-only"
|
|
label = "Actions Only"
|
|
kind = "remote"
|
|
capabilities = ("art.read", "song.sync")
|
|
|
|
def query_page(self, **kwargs):
|
|
raise AssertionError("provider without library.read capability should not be dispatched")
|
|
|
|
def query_artists(self, **kwargs):
|
|
raise AssertionError("provider without library.read capability should not be dispatched")
|
|
|
|
def query_stats(self, **kwargs):
|
|
raise AssertionError("provider without library.read capability should not be dispatched")
|
|
|
|
def tuning_names(self):
|
|
raise AssertionError("provider without library.read capability should not be dispatched")
|
|
|
|
def get_art(self, song_id: str):
|
|
raise AssertionError("get_art should not be dispatched in this test")
|
|
|
|
def sync_song(self, song_id: str):
|
|
raise AssertionError("sync_song should not be dispatched in this test")
|
|
|
|
|
|
def test_local_provider_is_default_library_provider(server_mod, client):
|
|
_put(server_mod)
|
|
|
|
providers = client.get("/api/library/providers").json()["providers"]
|
|
local = next(provider for provider in providers if provider["id"] == "local")
|
|
assert local["label"] == "My Library"
|
|
assert local["kind"] == "local"
|
|
assert local["default"] is True
|
|
assert "library.read" in local["capabilities"]
|
|
|
|
default_payload = client.get("/api/library").json()
|
|
explicit_payload = client.get("/api/library", params={"provider": "local"}).json()
|
|
assert default_payload == explicit_payload
|
|
assert default_payload["songs"][0]["filename"] == "local.archive"
|
|
|
|
|
|
def test_registered_provider_handles_library_endpoints(server_mod, client):
|
|
provider = FakeLibraryProvider()
|
|
server_mod.register_library_provider(provider)
|
|
|
|
providers = client.get("/api/library/providers").json()["providers"]
|
|
remote = next(item for item in providers if item["id"] == "remote:frodo")
|
|
assert remote["label"] == "Frodo's Library"
|
|
assert remote["kind"] == "remote"
|
|
assert remote["default"] is False
|
|
assert remote["capabilities"] == ["art.read", "library.read", "song.sync"]
|
|
|
|
songs = client.get("/api/library", params={
|
|
"provider": "remote:frodo",
|
|
"q": "needle",
|
|
"page": "2",
|
|
"size": "12",
|
|
"sort": "title-desc",
|
|
"dir": "desc",
|
|
"format": "archive",
|
|
"favorites": "1",
|
|
"arrangements_has": "Lead,Rhythm",
|
|
"has_lyrics": "1",
|
|
"tunings": "E Standard,Drop D",
|
|
}).json()
|
|
assert songs["total"] == 1
|
|
assert songs["songs"][0]["title"] == "Remote Song"
|
|
assert provider.page_kwargs["q"] == "needle"
|
|
assert provider.page_kwargs["page"] == 2
|
|
assert provider.page_kwargs["size"] == 12
|
|
assert provider.page_kwargs["sort"] == "title-desc"
|
|
assert provider.page_kwargs["direction"] == "desc"
|
|
assert provider.page_kwargs["favorites_only"] is True
|
|
assert provider.page_kwargs["format_filter"] == "archive"
|
|
assert provider.page_kwargs["arrangements_has"] == ["Lead", "Rhythm"]
|
|
assert provider.page_kwargs["has_lyrics"] == 1
|
|
assert provider.page_kwargs["tunings"] == ["E Standard", "Drop D"]
|
|
|
|
artists = client.get("/api/library/artists", params={
|
|
"provider": "remote:frodo",
|
|
"letter": "R",
|
|
"page": "1",
|
|
"size": "5",
|
|
}).json()
|
|
assert artists["total_artists"] == 1
|
|
assert provider.artist_kwargs["letter"] == "R"
|
|
assert provider.artist_kwargs["page"] == 1
|
|
assert provider.artist_kwargs["size"] == 5
|
|
assert "sort" not in provider.artist_kwargs
|
|
|
|
stats = client.get("/api/library/stats", params={"provider": "remote:frodo"}).json()
|
|
assert stats["letters"] == {"R": 1}
|
|
assert "page" not in provider.stats_kwargs
|
|
assert "size" not in provider.stats_kwargs
|
|
# `sort` is forwarded to query_stats now (the v3 jump rail keys its
|
|
# present-letter breakdown on the active sort column); defaults to "artist".
|
|
assert provider.stats_kwargs.get("sort") == "artist"
|
|
|
|
tunings = client.get("/api/library/tuning-names", params={"provider": "remote:frodo"}).json()
|
|
assert tunings["tunings"][0]["name"] == "E Standard"
|
|
|
|
art = client.get("/api/library/providers/remote:frodo/songs/remote-song-id/art")
|
|
assert art.status_code == 200
|
|
assert art.content == b"fake-art"
|
|
assert art.headers["content-type"].startswith("image/png")
|
|
assert provider.art_song_id == "remote-song-id"
|
|
|
|
synced = client.post("/api/library/providers/remote:frodo/songs/remote-song-id/sync").json()
|
|
assert synced == {"ok": True, "filename": "synced.archive", "song_id": "remote-song-id"}
|
|
assert provider.sync_song_id == "remote-song-id"
|
|
|
|
|
|
def test_unknown_library_provider_returns_404(server_mod, client):
|
|
response = client.get("/api/library", params={"provider": "missing"})
|
|
assert response.status_code == 404
|
|
assert "Unknown library provider" in response.json()["detail"]
|
|
|
|
|
|
def test_provider_art_and_sync_report_unsupported_when_missing(server_mod, client):
|
|
server_mod.register_library_provider(ReadOnlyLibraryProvider())
|
|
|
|
art = client.get("/api/library/providers/remote:readonly/songs/remote-song-id/art")
|
|
assert art.status_code == 501
|
|
assert "does not declare capability 'art.read'" in art.json()["detail"]
|
|
|
|
synced = client.post("/api/library/providers/remote:readonly/songs/remote-song-id/sync")
|
|
assert synced.status_code == 501
|
|
assert "does not declare capability 'song.sync'" in synced.json()["detail"]
|
|
|
|
|
|
def test_library_read_endpoints_require_library_read_capability(server_mod, client):
|
|
server_mod.register_library_provider(NonBrowsableLibraryProvider())
|
|
|
|
responses = [
|
|
client.get("/api/library", params={"provider": "remote:actions-only"}),
|
|
client.get("/api/library/artists", params={"provider": "remote:actions-only"}),
|
|
client.get("/api/library/stats", params={"provider": "remote:actions-only"}),
|
|
client.get("/api/library/tuning-names", params={"provider": "remote:actions-only"}),
|
|
]
|
|
for response in responses:
|
|
assert response.status_code == 501
|
|
assert "does not declare capability 'library.read'" in response.json()["detail"]
|
|
|
|
|
|
def test_local_library_provider_cannot_be_replaced(server_mod):
|
|
provider = FakeLibraryProvider()
|
|
provider.id = "local"
|
|
provider.label = "Not Actually Local"
|
|
|
|
try:
|
|
server_mod.register_library_provider(provider, replace=True)
|
|
except ValueError as exc:
|
|
assert "local library provider cannot be replaced" in str(exc)
|
|
else:
|
|
raise AssertionError("expected replacing the local provider to fail")
|
|
|
|
|
|
def test_library_provider_registration_is_available_to_plugins(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
|
sys.modules.pop("server", None)
|
|
server = importlib.import_module("server")
|
|
|
|
captured = {}
|
|
|
|
def _capturing_load_plugins(app, context, **kwargs):
|
|
captured.update(context)
|
|
|
|
monkeypatch.setattr(server, "load_plugins", _capturing_load_plugins)
|
|
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
|
|
|
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
|
try:
|
|
with TestClient(server.app):
|
|
assert captured["library_providers"] is server.library_providers
|
|
assert captured["register_library_provider"] is server.register_library_provider
|
|
assert captured["unregister_library_provider"] is server.unregister_library_provider
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|