From 99cfda2b082c781f654eda8df3f0acf6507723cc Mon Sep 17 00:00:00 2001 From: Lokman Caliskan Date: Sun, 4 Oct 2026 16:39:16 +0200 Subject: [PATCH] feat: mehr-laden paging (suche + entdecken, totals, fallback-skip) --- backend/app/api/discover.py | 9 ++- backend/app/api/search.py | 7 ++- backend/app/services/streamrip_service.py | 75 ++++++++++++++--------- backend/tests/test_discover.py | 38 +++++++++++- backend/tests/test_discover_extra.py | 8 +-- backend/tests/test_discover_fallback.py | 7 +++ backend/tests/test_podcast.py | 4 +- backend/tests/test_search.py | 4 +- frontend/app/discover/page.tsx | 65 +++++++++++++++++--- frontend/app/page.tsx | 49 +++++++++++++-- 10 files changed, 210 insertions(+), 56 deletions(-) diff --git a/backend/app/api/discover.py b/backend/app/api/discover.py index 7274f84..0a446cd 100644 --- a/backend/app/api/discover.py +++ b/backend/app/api/discover.py @@ -38,9 +38,10 @@ async def discover_radios(genre_id: int = Query(0, ge=0)): @router.get("/api/discover/radios/{radio_id}/tracks") -async def discover_radio_tracks(radio_id: int, limit: int = Query(25, ge=1, le=50)): +async def discover_radio_tracks(radio_id: int, limit: int = Query(25, ge=1, le=50), index: int = Query(0, ge=0, le=1000)): try: - return {"tracks": await get_radio_tracks(radio_id, limit)} + tracks, total = await get_radio_tracks(radio_id, limit, index) + return {"tracks": tracks, "total": total} except Exception as e: raise HTTPException(status_code=502, detail=f"Radio tracks failed: {e}") @@ -50,8 +51,10 @@ async def discover( kind: Literal["tracks", "albums", "artists", "playlists", "new"] = Query(...), genre_id: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=50), + index: int = Query(0, ge=0, le=1000), ): try: - return {"kind": kind, "results": await get_discover(kind, genre_id, limit)} + results, total = await get_discover(kind, genre_id, limit, index) + return {"kind": kind, "results": results, "total": total, "index": index} except Exception as e: raise HTTPException(status_code=502, detail=f"Discover failed: {e}") diff --git a/backend/app/api/search.py b/backend/app/api/search.py index 0cbd454..c4eae39 100644 --- a/backend/app/api/search.py +++ b/backend/app/api/search.py @@ -14,15 +14,16 @@ async def search( type: MediaType = Query(...), q: str = Query(..., min_length=0, max_length=200), limit: int = Query(20, ge=1, le=100), + index: int = Query(0, ge=0, le=1000), ): query = q.strip() if not query: raise HTTPException(status_code=400, detail="Empty query") try: if type == "podcast": - results = await streamrip_service.search_podcasts(query, limit) + results, total = await streamrip_service.search_podcasts(query, limit, index) else: - results = await streamrip_service.search_deezer(type, query, limit) + results, total = await streamrip_service.search_deezer(type, query, limit, index) except Exception as e: raise HTTPException(status_code=502, detail=f"Search failed: {e}") - return {"results": results, "type": type, "query": query} + return {"results": results, "type": type, "query": query, "total": total, "index": index} diff --git a/backend/app/services/streamrip_service.py b/backend/app/services/streamrip_service.py index 32b8331..30b3cf7 100644 --- a/backend/app/services/streamrip_service.py +++ b/backend/app/services/streamrip_service.py @@ -87,21 +87,30 @@ def _summarize(media_type: str, item: dict) -> dict: return base -async def search_deezer(media_type: str, query: str, limit: int = 20) -> list[dict]: - """Live search via streamrip DeezerClient. Returns UI-ready dicts.""" +async def search_deezer( + media_type: str, query: str, limit: int = 20, index: int = 0 +) -> tuple[list[dict], int | None]: + """Live search via Deezer API (paged). Returns (UI-ready dicts, total).""" + import asyncio + from streamrip.rip.main import Main config = _load_config() main = Main(config) try: client = await main.get_logged_in_client("deezer") - pages = await client.search(media_type, query, limit=limit) + try: + search_fn = getattr(client.client.api, f"search_{media_type}") + except AttributeError: + raise Exception(f"Invalid media type {media_type}") + resp = await asyncio.to_thread(search_fn, query, False, None, index, limit) finally: await main.__aexit__() - if not pages: - return [] - items = pages[0].get("data", [])[:limit] - return [_summarize(media_type, item) for item in items] + if not resp or not resp.get("data"): + return [], 0 + total = resp.get("total") + items = resp.get("data", [])[:limit] + return [_summarize(media_type, item) for item in items], total def _group_artist_albums(items: list[dict]) -> list[dict]: @@ -440,7 +449,7 @@ async def get_genres() -> list[dict]: ] -async def _playlist_top_tracks(api, genre_id: int, limit: int) -> list[dict]: +async def _playlist_top_tracks(api, genre_id: int, limit: int, skip: int = 0) -> list[dict]: """Raw track items from the top chart playlists (fallback source).""" import asyncio @@ -448,9 +457,10 @@ async def _playlist_top_tracks(api, genre_id: int, limit: int) -> list[dict]: playlists = resp.get("data", []) if isinstance(resp, dict) else [] tracks: list[dict] = [] seen: set[str] = set() + skipped = 0 for pl in playlists[:3]: try: - tresp = await asyncio.to_thread(api.get_playlist_tracks, pl.get("id"), 0, limit) + tresp = await asyncio.to_thread(api.get_playlist_tracks, pl.get("id"), 0, limit + skip) except Exception: continue for item in tresp.get("data", []) if isinstance(tresp, dict) else []: @@ -458,6 +468,9 @@ async def _playlist_top_tracks(api, genre_id: int, limit: int) -> list[dict]: if tid in seen: continue seen.add(tid) + if skipped < skip: + skipped += 1 + continue tracks.append(item) if len(tracks) >= limit: return tracks @@ -501,7 +514,7 @@ async def _discover_albums_fallback(api, genre_id: int, limit: int, primary: lis return out[:limit] -async def get_discover(kind: str, genre_id: int = 0, limit: int = 20) -> list[dict]: +async def get_discover(kind: str, genre_id: int = 0, limit: int = 20, index: int = 0) -> tuple[list[dict], int | None]: """Charts per kind with fallbacks (several Deezer endpoints are empty).""" import asyncio @@ -513,31 +526,35 @@ async def get_discover(kind: str, genre_id: int = 0, limit: int = 20) -> list[di client = await main.get_logged_in_client("deezer") api = client.client.api if kind == "tracks": - resp = await asyncio.to_thread(api.get_chart_tracks, genre_id, 0, limit) + resp = await asyncio.to_thread(api.get_chart_tracks, genre_id, index, limit) items = resp.get("data", []) + total = resp.get("total") if not items: - items = await _playlist_top_tracks(api, genre_id, limit) - return [_summarize("track", i) for i in items[:limit]] + items = await _playlist_top_tracks(api, genre_id, limit, skip=index) + total = None + return [_summarize("track", i) for i in items[:limit]], total if kind == "albums": - resp = await asyncio.to_thread(api.get_chart_albums, genre_id, 0, limit) + resp = await asyncio.to_thread(api.get_chart_albums, genre_id, index, limit) items = resp.get("data", []) - return [_summarize("album", i) for i in items[:limit]] + return [_summarize("album", i) for i in items[:limit]], resp.get("total") if kind == "artists": - resp = await asyncio.to_thread(api.get_chart_artists, genre_id, 0, limit) + resp = await asyncio.to_thread(api.get_chart_artists, genre_id, index, limit) items = resp.get("data", []) + total = resp.get("total") if not items: - raw = await _playlist_top_tracks(api, genre_id, limit * 2) + raw = await _playlist_top_tracks(api, genre_id, limit * 2, skip=index * 2) items = _artists_from_track_items(raw)[:limit] - return [_summarize("artist", i) for i in items[:limit]] + total = None + return [_summarize("artist", i) for i in items[:limit]], total if kind == "playlists": - resp = await asyncio.to_thread(api.get_chart_playlists, genre_id, 0, limit) + resp = await asyncio.to_thread(api.get_chart_playlists, genre_id, index, limit) items = resp.get("data", []) - return [_summarize("playlist", i) for i in items[:limit]] + return [_summarize("playlist", i) for i in items[:limit]], resp.get("total") # kind == "new": editorial selection, chart albums (newest first) as fallback - resp = await asyncio.to_thread(api.get_editorial_selection, genre_id, 0, limit) + resp = await asyncio.to_thread(api.get_editorial_selection, genre_id, index, limit) items = resp.get("data", []) if isinstance(resp, dict) else [] items = await _discover_albums_fallback(api, genre_id, limit, items) - return [_summarize("album", i) for i in items[:limit]] + return [_summarize("album", i) for i in items[:limit]], resp.get("total") if isinstance(resp, dict) else None finally: await main.__aexit__() @@ -569,7 +586,7 @@ async def get_genre_radios(genre_id: int) -> list[dict]: ] -async def get_radio_tracks(radio_id: int, limit: int = 25) -> list[dict]: +async def get_radio_tracks(radio_id: int, limit: int = 25, index: int = 0) -> tuple[list[dict], int | None]: """Top tracks of a radio station (loadable via batch download).""" import asyncio @@ -579,11 +596,12 @@ async def get_radio_tracks(radio_id: int, limit: int = 25) -> list[dict]: main = Main(config) try: client = await main.get_logged_in_client("deezer") - resp = await asyncio.to_thread(client.client.api.get_radio_tracks, radio_id, limit) + resp = await asyncio.to_thread(client.client.api.get_radio_tracks, radio_id, index, limit) finally: await main.__aexit__() items = resp.get("data", []) if isinstance(resp, dict) else [] - return [_summarize("track", i) for i in items[:limit]] + total = resp.get("total") if isinstance(resp, dict) else None + return [_summarize("track", i) for i in items[:limit]], total def _apply_job_overrides(config, quality: int | None, codec: str | None, folder: str | None) -> str: @@ -620,7 +638,7 @@ def _apply_job_overrides(config, quality: int | None, codec: str | None, folder: return effective_folder -async def search_podcasts(query: str, limit: int = 20) -> list[dict]: +async def search_podcasts(query: str, limit: int = 20, index: int = 0) -> tuple[list[dict], int | None]: """Search Deezer shows/podcasts (listening only, no download support).""" import asyncio @@ -631,11 +649,12 @@ async def search_podcasts(query: str, limit: int = 20) -> list[dict]: try: client = await main.get_logged_in_client("deezer") resp = await asyncio.to_thread( - client.client.api.api_call, "search/podcast", {"q": query, "limit": limit} + client.client.api.api_call, "search/podcast", {"q": query, "index": index, "limit": limit} ) finally: await main.__aexit__() items = resp.get("data", []) if isinstance(resp, dict) else [] + total = resp.get("total") if isinstance(resp, dict) else None out = [] for item in items[:limit]: out.append( @@ -650,7 +669,7 @@ async def search_podcasts(query: str, limit: int = 20) -> list[dict]: "fans": item.get("fans"), } ) - return out + return out, total async def get_podcast_details(show_id: str) -> dict: diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index 1311f16..c8de144 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -31,14 +31,46 @@ def test_genres_mocked(monkeypatch): def test_discover_mocked(monkeypatch): from app.api import discover as disc - async def fake_discover(kind, genre_id, limit): - assert kind in ("tracks", "albums", "artists", "new") - return [{"id": "1", "title": "X"}] + async def fake_discover(kind, genre_id, limit, index=0): + assert kind in ("tracks", "albums", "artists", "playlists", "new") + return [{"id": "1", "title": "X"}], 100 monkeypatch.setattr(disc, "get_discover", fake_discover) r = c.get("/api/discover", params={"kind": "tracks", "genre_id": 132, "limit": 5}) assert r.status_code == 200 assert len(r.json()["results"]) == 1 + assert r.json()["total"] == 100 + + +def test_discover_forwards_index(monkeypatch): + from app.api import discover as disc + + seen = {} + + async def fake_discover(kind, genre_id, limit, index=0): + seen["index"] = index + return [], 100 + + monkeypatch.setattr(disc, "get_discover", fake_discover) + r = c.get("/api/discover", params={"kind": "tracks", "index": 24}) + assert r.status_code == 200 + assert seen["index"] == 24 + + +def test_search_forwards_index(monkeypatch): + from app.services import streamrip_service as svc + + seen = {} + + async def fake_search(media_type, query, limit, index=0): + seen["index"] = index + return [], 50 + + monkeypatch.setattr(svc, "search_deezer", fake_search) + r = c.get("/api/search", params={"type": "track", "q": "x", "index": 20}) + assert r.status_code == 200 + assert seen["index"] == 20 + assert r.json()["total"] == 50 def test_discover_rejects_bad_kind(): diff --git a/backend/tests/test_discover_extra.py b/backend/tests/test_discover_extra.py index ad7bc76..dc385d6 100644 --- a/backend/tests/test_discover_extra.py +++ b/backend/tests/test_discover_extra.py @@ -7,9 +7,9 @@ c = TestClient(app) def test_discover_playlists_mocked(monkeypatch): from app.api import discover as disc - async def fake_discover(kind, genre_id, limit): + async def fake_discover(kind, genre_id, limit, index=0): assert kind == "playlists" - return [{"id": "11", "title": "Mix", "media_type": "playlist"}] + return [{"id": "11", "title": "Mix", "media_type": "playlist"}], 3 monkeypatch.setattr(disc, "get_discover", fake_discover) r = c.get("/api/discover", params={"kind": "playlists"}) @@ -33,8 +33,8 @@ def test_radios_mocked(monkeypatch): def test_radio_tracks_mocked(monkeypatch): from app.api import discover as disc - async def fake_tracks(radio_id, limit): - return [{"id": "5", "title": "Song", "media_type": "track"}] + async def fake_tracks(radio_id, limit, index=0): + return [{"id": "5", "title": "Song", "media_type": "track"}], 10 monkeypatch.setattr(disc, "get_radio_tracks", fake_tracks) r = c.get("/api/discover/radios/1/tracks") diff --git a/backend/tests/test_discover_fallback.py b/backend/tests/test_discover_fallback.py index 80f2e01..97c6caa 100644 --- a/backend/tests/test_discover_fallback.py +++ b/backend/tests/test_discover_fallback.py @@ -55,3 +55,10 @@ def test_new_fills_up_to_limit_without_dupes(): albums = asyncio.run(_discover_albums_fallback(FakeApi(), 0, 3, primary=primary)) assert [a["title"] for a in albums] == ["New", "Extra", "Old"] assert len({a["id"] for a in albums}) == 3 + + +def test_playlist_top_tracks_skip(): + from app.services.streamrip_service import _playlist_top_tracks + + tracks = asyncio.run(_playlist_top_tracks(FakeApi(), 0, 10, skip=2)) + assert [t["id"] for t in tracks] == [3] diff --git a/backend/tests/test_podcast.py b/backend/tests/test_podcast.py index ce77463..b833da6 100644 --- a/backend/tests/test_podcast.py +++ b/backend/tests/test_podcast.py @@ -7,13 +7,13 @@ c = TestClient(app) def test_podcast_search_mocked(monkeypatch): from app.services import streamrip_service as svc - async def fake_podcasts(query, limit): + async def fake_podcasts(query, limit, index=0): assert query == "flauschig" return [{ "source": "deezer", "media_type": "podcast", "id": "100", "title": "Fest & Flauschig", "artist": "", "desc": "Fest & Flauschig", "cover": None, - }] + }], 1 monkeypatch.setattr(svc, "search_podcasts", fake_podcasts) r = c.get("/api/search", params={"type": "podcast", "q": "flauschig"}) diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py index 4554851..54a9d17 100644 --- a/backend/tests/test_search.py +++ b/backend/tests/test_search.py @@ -17,7 +17,7 @@ def test_search_rejects_invalid_type(): def test_search_track_mocked(monkeypatch): from app.services import streamrip_service as svc - async def fake_search(media_type, query, limit): + async def fake_search(media_type, query, limit, index=0): assert media_type == "track" return [ { @@ -31,7 +31,7 @@ def test_search_track_mocked(monkeypatch): "preview_url": None, "duration": None, } - ] + ], 1 monkeypatch.setattr(svc, "search_deezer", fake_search) r = c.get("/api/search", params={"type": "track", "q": "test"}) diff --git a/frontend/app/discover/page.tsx b/frontend/app/discover/page.tsx index e0108b8..61975df 100644 --- a/frontend/app/discover/page.tsx +++ b/frontend/app/discover/page.tsx @@ -21,8 +21,12 @@ export default function DiscoverPage() { const [genreId, setGenreId] = useState(0); const [kind, setKind] = useState("new"); const [results, setResults] = useState([]); - const [radios, setRadios] = useState<{ id: number; title: string; cover?: string | null }[]>([]); + const [total, setTotal] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [radios, setRadios] = useState<{ id: number; title: string; cover?: string | null }[]>([]); const [error, setError] = useState(""); const [selected, setSelected] = useState(null); const [radioMsg, setRadioMsg] = useState(""); @@ -35,15 +39,23 @@ export default function DiscoverPage() { fetch("/api/discover/genres").then((r) => r.json()).then((b) => setGenres(b.genres ?? [])).catch(() => {}); }, []); + const LIMIT = 24; + + async function fetchDiscover(k: Kind, g: number, idx: number, append: boolean) { + const r = await fetch(`/api/discover?kind=${k}&genre_id=${g}&limit=${LIMIT}&index=${idx}`); + if (!r.ok) throw new Error(`Fehler (${r.status})`); + const b = await r.json(); + const batch = b.results ?? []; + setResults((prev) => (append ? [...prev, ...batch] : batch)); + setTotal(typeof b.total === "number" ? b.total : null); + setPageIndex(idx); + setHasMore(batch.length === LIMIT); + } + useEffect(() => { setLoading(true); setError(""); - fetch(`/api/discover?kind=${kind}&genre_id=${genreId}&limit=24`) - .then((r) => { - if (!r.ok) throw new Error(`Fehler (${r.status})`); - return r.json(); - }) - .then((b) => setResults(b.results ?? [])) + fetchDiscover(kind, genreId, 0, false) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler.")) .finally(() => setLoading(false)); fetch(`/api/discover/radios?genre_id=${genreId}`) @@ -52,6 +64,19 @@ export default function DiscoverPage() { .catch(() => {}); }, [kind, genreId, nonce]); + async function loadMore() { + setLoadingMore(true); + try { + await fetchDiscover(kind, genreId, pageIndex + LIMIT, true); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Fehler."); + } finally { + setLoadingMore(false); + } + } + + const canLoadMore = !loading && results.length > 0 && (total === null || results.length < total); + async function toggleRadio(radioId: number) { if (openRadio === radioId) { setOpenRadio(null); @@ -137,6 +162,32 @@ export default function DiscoverPage() { ); })} + {!loading && results.length > 0 && ( +

+ {results.length}{total !== null && total > results.length ? ` von ${total}` : ""} Einträge +

+ )} + {!loading && hasMore && ( +
+ +
+ )} {selected && setSelected(null)} />} {radios.length > 0 && ( diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index d452d6f..945a07a 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -20,13 +20,31 @@ export default function SearchPage() { const [q, setQ] = useState(""); const [tab, setTab] = useState("track"); const [results, setResults] = useState([]); + const [total, setTotal] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(""); const [searched, setSearched] = useState(false); const [selected, setSelected] = useState(null); const [link, setLink] = useState(""); const [linkMsg, setLinkMsg] = useState(""); + const LIMIT = 24; + + async function fetchPage(query: string, mediaType: MediaType, idx: number, append: boolean) { + const r = await fetch(`/api/search?type=${mediaType}&q=${encodeURIComponent(query)}&limit=${LIMIT}&index=${idx}`); + if (!r.ok) throw new Error(hintFor(r.status)); + const body = await r.json(); + const batch = body.results ?? []; + setResults((prev) => (append ? [...prev, ...batch] : batch)); + setTotal(typeof body.total === "number" ? body.total : null); + setPageIndex(idx); + // Volle Seite => evtl. mehr da (Deezer-Totals bei Charts lügen teils). + setHasMore(batch.length === LIMIT); + } + async function importLink() { if (!link.trim()) return; setLinkMsg(""); @@ -54,18 +72,29 @@ export default function SearchPage() { setError(""); setSearched(true); try { - const r = await fetch(`/api/search?type=${tab}&q=${encodeURIComponent(q)}&limit=24`); - if (!r.ok) throw new Error(hintFor(r.status)); - const body = await r.json(); - setResults(body.results ?? []); + await fetchPage(q, tab, 0, false); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Suche fehlgeschlagen."); setResults([]); + setTotal(null); } finally { setLoading(false); } } + async function loadMore() { + setLoadingMore(true); + try { + await fetchPage(q, tab, pageIndex + LIMIT, true); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Suche fehlgeschlagen."); + } finally { + setLoadingMore(false); + } + } + + const canLoadMore = searched && !loading && hasMore; + return (

Was willst du laden?

@@ -116,6 +145,11 @@ export default function SearchPage() { {!loading && searched && results.length === 0 && !error && (

Keine Treffer — anders formulieren oder anderen Typ wählen.

)} + {searched && !loading && results.length > 0 && ( +

+ {results.length}{total !== null && total > results.length ? ` von ${total}` : ""} Treffer +

+ )}
{results.map((item) => { const detailHref = @@ -176,6 +210,13 @@ export default function SearchPage() { ); })}
+ {canLoadMore && ( +
+ +
+ )} {selected && setSelected(null)} />}
);