"""Search endpoints: global and library-scoped.""" from fastapi import APIRouter, Query from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, SourceRegistryDep, TrackRepoDep from app.api.schemas.album import AlbumOut from app.api.schemas.artist import ArtistOut from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut from app.api.schemas.search import LibrarySearchResponse from app.api.schemas.track import TrackOut from app.api.v1.albums import _build_album_out from app.api.v1.tracks import _build_track_out router = APIRouter(prefix="/search", tags=["search"]) @router.get("") async def search( _: CurrentUser, registry: SourceRegistryDep, track_repo: TrackRepoDep, q: str = Query(min_length=1), limit: int = Query(20, ge=1, le=50), ) -> ExternalSearchResponse: """Search every available fetch source and merge the hits (§A4 discover). A source that is down contributes nothing rather than failing the whole request (graceful degradation); only available sources are reported as searched. Each hit is checked against the library by ``(source, source_id)`` so the UI can show "Saved"/"Play" instead of "Save to library" without a separate round-trip (remote browse, plan: Model C).""" results: list[ExternalSearchResultOut] = [] searched: list[str] = [] for backend in registry.searchables(): if not backend.is_available(): continue searched.append(backend.name) hits = await backend.search(q, limit=limit) for h in hits: existing = await track_repo.get_by_source(h.source, h.source_id) results.append(ExternalSearchResultOut.from_entity(h, existing=existing)) return ExternalSearchResponse(results=results, searched_sources=searched) @router.get("/library") async def search_library( track_repo: TrackRepoDep, artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep, _: CurrentUser, q: str = Query(min_length=1), types: str = Query(default="tracks,albums,artists"), limit: int = Query(20, ge=1, le=100), ) -> LibrarySearchResponse: requested = {t.strip() for t in types.split(",")} tracks_out: list[TrackOut] = [] albums_out: list[AlbumOut] = [] artists_out: list[ArtistOut] = [] if "tracks" in requested: tracks = await track_repo.list( artist_id=None, album_id=None, q=q, sort_by="title", order="asc", limit=limit, offset=0, ) if tracks: artist_ids = list({t.artist_id for t in tracks}) album_ids = list({t.album_id for t in tracks if t.album_id is not None}) artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)} albums_map = {a.id: a for a in await album_repo.get_many(album_ids)} tracks_out = await _build_track_out(tracks, artists_map, albums_map) if "albums" in requested: albums = await album_repo.list(artist_id=None, q=q, limit=limit, offset=0) if albums: artist_ids = list({a.artist_id for a in albums}) artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)} track_counts = await album_repo.track_count_many([a.id for a in albums]) albums_out = await _build_album_out(albums, artists_map, track_counts) if "artists" in requested: raw_artists = await artist_repo.list(q=q, limit=limit, offset=0) for a in raw_artists: album_cnt = await artist_repo.album_count(a.id) track_cnt = await artist_repo.track_count(a.id) artists_out.append( ArtistOut( id=a.id, name=a.name, album_count=album_cnt, track_count=track_cnt, created_at=a.created_at, ) ) return LibrarySearchResponse(tracks=tracks_out, albums=albums_out, artists=artists_out)