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")
|
@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}")
|
||||||
@@ -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}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user