feat: mehr-laden paging (suche + entdecken, totals, fallback-skip)
This commit is contained in:
1 parent
4fc6ec121f
commit
99cfda2b08
10 files changed
+210
-56
No files matched your search
@@ -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}")
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -21,8 +21,12 @@ export default function DiscoverPage() {
|
||||
const [genreId, setGenreId] = useState(0);
|
||||
const [kind, setKind] = useState<Kind>("new");
|
||||
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 [loadingMore, setLoadingMore] = useState(false);
|
||||
const [radios, setRadios] = useState<{ id: number; title: string; cover?: string | null }[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<SearchItem | null>(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() {
|
||||
);
|
||||
})}
|
||||
</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)} />}
|
||||
|
||||
{radios.length > 0 && (
|
||||
|
||||
+45
-4
@@ -20,13 +20,31 @@ export default function SearchPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [tab, setTab] = useState<MediaType>("track");
|
||||
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 [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [selected, setSelected] = useState<SearchItem | null>(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 (
|
||||
<div>
|
||||
<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 && (
|
||||
<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 }}>
|
||||
{results.map((item) => {
|
||||
const detailHref =
|
||||
@@ -176,6 +210,13 @@ export default function SearchPage() {
|
||||
);
|
||||
})}
|
||||
</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)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user