feat: mehr-laden paging (suche + entdecken, totals, fallback-skip)
CI / backend-tests (push) Successful in 8s
Docker images / backend (push) Successful in 7s
Docker images / frontend (push) Successful in 22s
CI / docker (push) Successful in 3s

This commit is contained in:
loki committed 2026-10-04 16:39:16 +02:00
1 parent 4fc6ec121f
commit 99cfda2b08
10 files changed
+210 -56

No files matched your search

+6 -3
View File
@@ -38,9 +38,10 @@ async def discover_radios(genre_id: int = Query(0, ge=0)):
@router.get("/api/discover/radios/{radio_id}/tracks") @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: 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: except Exception as e:
raise HTTPException(status_code=502, detail=f"Radio tracks failed: {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(...), kind: Literal["tracks", "albums", "artists", "playlists", "new"] = Query(...),
genre_id: int = Query(0, ge=0), genre_id: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=50), limit: int = Query(20, ge=1, le=50),
index: int = Query(0, ge=0, le=1000),
): ):
try: 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: except Exception as e:
raise HTTPException(status_code=502, detail=f"Discover failed: {e}") raise HTTPException(status_code=502, detail=f"Discover failed: {e}")
+4 -3
View File
@@ -14,15 +14,16 @@ async def search(
type: MediaType = Query(...), type: MediaType = Query(...),
q: str = Query(..., min_length=0, max_length=200), q: str = Query(..., min_length=0, max_length=200),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
index: int = Query(0, ge=0, le=1000),
): ):
query = q.strip() query = q.strip()
if not query: if not query:
raise HTTPException(status_code=400, detail="Empty query") raise HTTPException(status_code=400, detail="Empty query")
try: try:
if type == "podcast": if type == "podcast":
results = await streamrip_service.search_podcasts(query, limit) results, total = await streamrip_service.search_podcasts(query, limit, index)
else: 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: except Exception as e:
raise HTTPException(status_code=502, detail=f"Search failed: {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}
+47 -28
View File
@@ -87,21 +87,30 @@ def _summarize(media_type: str, item: dict) -> dict:
return base return base
async def search_deezer(media_type: str, query: str, limit: int = 20) -> list[dict]: async def search_deezer(
"""Live search via streamrip DeezerClient. Returns UI-ready dicts.""" 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 from streamrip.rip.main import Main
config = _load_config() config = _load_config()
main = Main(config) main = Main(config)
try: try:
client = await main.get_logged_in_client("deezer") 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: finally:
await main.__aexit__() await main.__aexit__()
if not pages: if not resp or not resp.get("data"):
return [] return [], 0
items = pages[0].get("data", [])[:limit] total = resp.get("total")
return [_summarize(media_type, item) for item in items] items = resp.get("data", [])[:limit]
return [_summarize(media_type, item) for item in items], total
def _group_artist_albums(items: list[dict]) -> list[dict]: 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).""" """Raw track items from the top chart playlists (fallback source)."""
import asyncio 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 [] playlists = resp.get("data", []) if isinstance(resp, dict) else []
tracks: list[dict] = [] tracks: list[dict] = []
seen: set[str] = set() seen: set[str] = set()
skipped = 0
for pl in playlists[:3]: for pl in playlists[:3]:
try: 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: except Exception:
continue continue
for item in tresp.get("data", []) if isinstance(tresp, dict) else []: 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: if tid in seen:
continue continue
seen.add(tid) seen.add(tid)
if skipped < skip:
skipped += 1
continue
tracks.append(item) tracks.append(item)
if len(tracks) >= limit: if len(tracks) >= limit:
return tracks return tracks
@@ -501,7 +514,7 @@ async def _discover_albums_fallback(api, genre_id: int, limit: int, primary: lis
return out[:limit] 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).""" """Charts per kind with fallbacks (several Deezer endpoints are empty)."""
import asyncio 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") client = await main.get_logged_in_client("deezer")
api = client.client.api api = client.client.api
if kind == "tracks": 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", []) items = resp.get("data", [])
total = resp.get("total")
if not items: if not items:
items = await _playlist_top_tracks(api, genre_id, limit) items = await _playlist_top_tracks(api, genre_id, limit, skip=index)
return [_summarize("track", i) for i in items[:limit]] total = None
return [_summarize("track", i) for i in items[:limit]], total
if kind == "albums": 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", []) 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": 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", []) items = resp.get("data", [])
total = resp.get("total")
if not items: 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] 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": 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", []) 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 # 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 = resp.get("data", []) if isinstance(resp, dict) else []
items = await _discover_albums_fallback(api, genre_id, limit, items) 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: finally:
await main.__aexit__() 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).""" """Top tracks of a radio station (loadable via batch download)."""
import asyncio import asyncio
@@ -579,11 +596,12 @@ async def get_radio_tracks(radio_id: int, limit: int = 25) -> list[dict]:
main = Main(config) main = Main(config)
try: try:
client = await main.get_logged_in_client("deezer") 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: finally:
await main.__aexit__() await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else [] 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: 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 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).""" """Search Deezer shows/podcasts (listening only, no download support)."""
import asyncio import asyncio
@@ -631,11 +649,12 @@ async def search_podcasts(query: str, limit: int = 20) -> list[dict]:
try: try:
client = await main.get_logged_in_client("deezer") client = await main.get_logged_in_client("deezer")
resp = await asyncio.to_thread( 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: finally:
await main.__aexit__() await main.__aexit__()
items = resp.get("data", []) if isinstance(resp, dict) else [] items = resp.get("data", []) if isinstance(resp, dict) else []
total = resp.get("total") if isinstance(resp, dict) else None
out = [] out = []
for item in items[:limit]: for item in items[:limit]:
out.append( out.append(
@@ -650,7 +669,7 @@ async def search_podcasts(query: str, limit: int = 20) -> list[dict]:
"fans": item.get("fans"), "fans": item.get("fans"),
} }
) )
return out return out, total
async def get_podcast_details(show_id: str) -> dict: async def get_podcast_details(show_id: str) -> dict:
+35 -3
View File
@@ -31,14 +31,46 @@ def test_genres_mocked(monkeypatch):
def test_discover_mocked(monkeypatch): def test_discover_mocked(monkeypatch):
from app.api import discover as disc 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 in ("tracks", "albums", "artists", "new") assert kind in ("tracks", "albums", "artists", "playlists", "new")
return [{"id": "1", "title": "X"}] return [{"id": "1", "title": "X"}], 100
monkeypatch.setattr(disc, "get_discover", fake_discover) monkeypatch.setattr(disc, "get_discover", fake_discover)
r = c.get("/api/discover", params={"kind": "tracks", "genre_id": 132, "limit": 5}) r = c.get("/api/discover", params={"kind": "tracks", "genre_id": 132, "limit": 5})
assert r.status_code == 200 assert r.status_code == 200
assert len(r.json()["results"]) == 1 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(): def test_discover_rejects_bad_kind():
+4 -4
View File
@@ -7,9 +7,9 @@ c = TestClient(app)
def test_discover_playlists_mocked(monkeypatch): def test_discover_playlists_mocked(monkeypatch):
from app.api import discover as disc 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" 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) monkeypatch.setattr(disc, "get_discover", fake_discover)
r = c.get("/api/discover", params={"kind": "playlists"}) r = c.get("/api/discover", params={"kind": "playlists"})
@@ -33,8 +33,8 @@ def test_radios_mocked(monkeypatch):
def test_radio_tracks_mocked(monkeypatch): def test_radio_tracks_mocked(monkeypatch):
from app.api import discover as disc from app.api import discover as disc
async def fake_tracks(radio_id, limit): async def fake_tracks(radio_id, limit, index=0):
return [{"id": "5", "title": "Song", "media_type": "track"}] return [{"id": "5", "title": "Song", "media_type": "track"}], 10
monkeypatch.setattr(disc, "get_radio_tracks", fake_tracks) monkeypatch.setattr(disc, "get_radio_tracks", fake_tracks)
r = c.get("/api/discover/radios/1/tracks") r = c.get("/api/discover/radios/1/tracks")
+7
View File
@@ -55,3 +55,10 @@ def test_new_fills_up_to_limit_without_dupes():
albums = asyncio.run(_discover_albums_fallback(FakeApi(), 0, 3, primary=primary)) albums = asyncio.run(_discover_albums_fallback(FakeApi(), 0, 3, primary=primary))
assert [a["title"] for a in albums] == ["New", "Extra", "Old"] assert [a["title"] for a in albums] == ["New", "Extra", "Old"]
assert len({a["id"] for a in albums}) == 3 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]
+2 -2
View File
@@ -7,13 +7,13 @@ c = TestClient(app)
def test_podcast_search_mocked(monkeypatch): def test_podcast_search_mocked(monkeypatch):
from app.services import streamrip_service as svc 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" assert query == "flauschig"
return [{ return [{
"source": "deezer", "media_type": "podcast", "id": "100", "source": "deezer", "media_type": "podcast", "id": "100",
"title": "Fest & Flauschig", "artist": "", "title": "Fest & Flauschig", "artist": "",
"desc": "Fest & Flauschig", "cover": None, "desc": "Fest & Flauschig", "cover": None,
}] }], 1
monkeypatch.setattr(svc, "search_podcasts", fake_podcasts) monkeypatch.setattr(svc, "search_podcasts", fake_podcasts)
r = c.get("/api/search", params={"type": "podcast", "q": "flauschig"}) r = c.get("/api/search", params={"type": "podcast", "q": "flauschig"})
+2 -2
View File
@@ -17,7 +17,7 @@ def test_search_rejects_invalid_type():
def test_search_track_mocked(monkeypatch): def test_search_track_mocked(monkeypatch):
from app.services import streamrip_service as svc 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" assert media_type == "track"
return [ return [
{ {
@@ -31,7 +31,7 @@ def test_search_track_mocked(monkeypatch):
"preview_url": None, "preview_url": None,
"duration": None, "duration": None,
} }
] ], 1
monkeypatch.setattr(svc, "search_deezer", fake_search) monkeypatch.setattr(svc, "search_deezer", fake_search)
r = c.get("/api/search", params={"type": "track", "q": "test"}) r = c.get("/api/search", params={"type": "track", "q": "test"})
+58 -7
View File
@@ -21,8 +21,12 @@ export default function DiscoverPage() {
const [genreId, setGenreId] = useState(0); const [genreId, setGenreId] = useState(0);
const [kind, setKind] = useState<Kind>("new"); const [kind, setKind] = useState<Kind>("new");
const [results, setResults] = useState<SearchItem[]>([]); const [results, setResults] = useState<SearchItem[]>([]);
const [radios, setRadios] = useState<{ id: number; title: string; cover?: string | null }[]>([]); const [total, setTotal] = useState<number | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = 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 [error, setError] = useState("");
const [selected, setSelected] = useState<SearchItem | null>(null); const [selected, setSelected] = useState<SearchItem | null>(null);
const [radioMsg, setRadioMsg] = useState(""); 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(() => {}); 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(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(""); setError("");
fetch(`/api/discover?kind=${kind}&genre_id=${genreId}&limit=24`) fetchDiscover(kind, genreId, 0, false)
.then((r) => {
if (!r.ok) throw new Error(`Fehler (${r.status})`);
return r.json();
})
.then((b) => setResults(b.results ?? []))
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler.")) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Fehler."))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
fetch(`/api/discover/radios?genre_id=${genreId}`) fetch(`/api/discover/radios?genre_id=${genreId}`)
@@ -52,6 +64,19 @@ export default function DiscoverPage() {
.catch(() => {}); .catch(() => {});
}, [kind, genreId, nonce]); }, [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) { async function toggleRadio(radioId: number) {
if (openRadio === radioId) { if (openRadio === radioId) {
setOpenRadio(null); setOpenRadio(null);
@@ -137,6 +162,32 @@ export default function DiscoverPage() {
); );
})} })}
</div> </div>
{!loading && results.length > 0 && (
<p className="muted" style={{ fontSize: 13 }}>
{results.length}{total !== null && total > results.length ? ` von ${total}` : ""} Einträge
</p>
)}
{!loading && hasMore && (
<div style={{ textAlign: "center", margin: "20px 0" }}>
<button
onClick={async () => {
setLoadingMore(true);
try {
await fetchDiscover(kind, genreId, pageIndex + LIMIT, true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler.");
} finally {
setLoadingMore(false);
}
}}
disabled={loadingMore}
className="btn btn-primary"
style={{ padding: "10px 28px" }}
>
{loadingMore ? "Lädt …" : "Mehr laden"}
</button>
</div>
)}
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />} {selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
{radios.length > 0 && ( {radios.length > 0 && (
+45 -4
View File
@@ -20,13 +20,31 @@ export default function SearchPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [tab, setTab] = useState<MediaType>("track"); const [tab, setTab] = useState<MediaType>("track");
const [results, setResults] = useState<SearchItem[]>([]); const [results, setResults] = useState<SearchItem[]>([]);
const [total, setTotal] = useState<number | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const [selected, setSelected] = useState<SearchItem | null>(null); const [selected, setSelected] = useState<SearchItem | null>(null);
const [link, setLink] = useState(""); const [link, setLink] = useState("");
const [linkMsg, setLinkMsg] = 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() { async function importLink() {
if (!link.trim()) return; if (!link.trim()) return;
setLinkMsg(""); setLinkMsg("");
@@ -54,18 +72,29 @@ export default function SearchPage() {
setError(""); setError("");
setSearched(true); setSearched(true);
try { try {
const r = await fetch(`/api/search?type=${tab}&q=${encodeURIComponent(q)}&limit=24`); await fetchPage(q, tab, 0, false);
if (!r.ok) throw new Error(hintFor(r.status));
const body = await r.json();
setResults(body.results ?? []);
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "Suche fehlgeschlagen."); setError(e instanceof Error ? e.message : "Suche fehlgeschlagen.");
setResults([]); setResults([]);
setTotal(null);
} finally { } finally {
setLoading(false); 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 ( return (
<div> <div>
<h1 style={{ fontSize: 40, margin: "8px 0 4px", letterSpacing: "-0.02em" }}>Was willst du laden?</h1> <h1 style={{ fontSize: 40, margin: "8px 0 4px", letterSpacing: "-0.02em" }}>Was willst du laden?</h1>
@@ -116,6 +145,11 @@ export default function SearchPage() {
{!loading && searched && results.length === 0 && !error && ( {!loading && searched && results.length === 0 && !error && (
<p className="muted">Keine Treffer — anders formulieren oder anderen Typ wählen.</p> <p className="muted">Keine Treffer — anders formulieren oder anderen Typ wählen.</p>
)} )}
{searched && !loading && results.length > 0 && (
<p className="muted" style={{ fontSize: 13 }}>
{results.length}{total !== null && total > results.length ? ` von ${total}` : ""} Treffer
</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 14 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 14 }}>
{results.map((item) => { {results.map((item) => {
const detailHref = const detailHref =
@@ -176,6 +210,13 @@ export default function SearchPage() {
); );
})} })}
</div> </div>
{canLoadMore && (
<div style={{ textAlign: "center", margin: "20px 0" }}>
<button onClick={loadMore} disabled={loadingMore} className="btn btn-primary" style={{ padding: "10px 28px" }}>
{loadingMore ? "Lädt …" : "Mehr laden"}
</button>
</div>
)}
{selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />} {selected && <DownloadDialog item={selected} onClose={() => setSelected(null)} />}
</div> </div>
); );