78007461e1
Pluggable fetch source: ytmusicapi search + yt-dlp download (cookies-file guard), DownloadJob entity/repo + DownloadService, download_task worker with exponential-backoff retries, and wired /search, /sources/{source}/search, and /downloads endpoints. Adds youtube_enabled/cookies config, yt-dlp+ytmusicapi deps, and the download_jobs.track_id migration. Snapshot also bundles in-progress storage/tracks/acoustid edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
"""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,
|
|
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."""
|
|
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)
|
|
results.extend(ExternalSearchResultOut.from_entity(h) for h in hits)
|
|
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)
|