feat(sources): YouTube Music search + download pipeline (§1C/§1E)
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>
This commit is contained in:
+60
-18
@@ -1,36 +1,78 @@
|
||||
"""Download job endpoints. Heavy work is dispatched to arq workers."""
|
||||
"""Download job endpoints (§A5). Heavy work is dispatched to arq workers — these
|
||||
handlers only create/inspect/cancel/retry job records."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import CurrentUser, DownloadServiceDep
|
||||
from app.api.schemas.download import DownloadCreate, DownloadCreateResponse, DownloadJobOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
|
||||
router = APIRouter(prefix="/downloads", tags=["downloads"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_downloads() -> Any: ...
|
||||
async def list_downloads(
|
||||
service: DownloadServiceDep,
|
||||
user: CurrentUser,
|
||||
status: str | None = Query(default=None),
|
||||
mine: bool = Query(default=False),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[DownloadJobOut]:
|
||||
jobs, total = await service.list(
|
||||
requested_by=user.id if mine else None,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return PagedResponse(
|
||||
items=[DownloadJobOut.from_entity(j) for j in jobs],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_download() -> Any: ...
|
||||
@router.post("", status_code=202)
|
||||
async def create_download(
|
||||
body: DownloadCreate,
|
||||
service: DownloadServiceDep,
|
||||
user: CurrentUser,
|
||||
) -> DownloadCreateResponse:
|
||||
result = await service.request(
|
||||
source=body.source,
|
||||
source_id=body.source_id,
|
||||
query=body.query,
|
||||
requested_by=user.id,
|
||||
)
|
||||
return DownloadCreateResponse(
|
||||
already_in_library=result.already_in_library,
|
||||
track_id=result.track_id,
|
||||
job=DownloadJobOut.from_entity(result.job) if result.job is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
async def get_download(job_id: uuid.UUID) -> Any: ...
|
||||
async def get_download(
|
||||
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
|
||||
) -> DownloadJobOut:
|
||||
job = await service.get(job_id)
|
||||
return DownloadJobOut.from_entity(job)
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def cancel_download(job_id: uuid.UUID) -> Any: ...
|
||||
@router.delete("/{job_id}", status_code=204)
|
||||
async def cancel_download(
|
||||
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
|
||||
) -> Response:
|
||||
await service.cancel(job_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/{job_id}/retry")
|
||||
async def retry_download(job_id: uuid.UUID) -> Any: ...
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
async def pause_downloads() -> Any: ...
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
async def resume_downloads() -> Any: ...
|
||||
async def retry_download(
|
||||
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
|
||||
) -> DownloadJobOut:
|
||||
job = await service.retry(job_id)
|
||||
return DownloadJobOut.from_entity(job)
|
||||
|
||||
+22
-4
@@ -1,12 +1,11 @@
|
||||
"""Search endpoints: global and library-scoped."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep
|
||||
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
|
||||
@@ -16,7 +15,26 @@ router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def search(_: CurrentUser) -> Any: ...
|
||||
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")
|
||||
|
||||
+20
-9
@@ -1,14 +1,13 @@
|
||||
"""External source endpoints: enumerate sources and trigger imports.
|
||||
"""External source endpoints: enumerate sources, search, and trigger imports.
|
||||
|
||||
Listing/health are read-only (any authenticated user). Scanning a source is an
|
||||
admin action and runs in a worker — the endpoint only enqueues it.
|
||||
Listing/health/search are read-only (any authenticated user). Scanning a source
|
||||
is an admin action and runs in a worker — the endpoint only enqueues it.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser
|
||||
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
|
||||
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
|
||||
from app.domain.errors import DependencyUnavailableError
|
||||
from app.workers.queue import enqueue
|
||||
@@ -39,6 +38,18 @@ async def source_health(
|
||||
|
||||
|
||||
@router.get("/{source}/search")
|
||||
async def search_source(source: str, _: CurrentUser) -> Any:
|
||||
# Search is for fetch-style sources (youtube, …) — not yet implemented.
|
||||
...
|
||||
async def search_source(
|
||||
source: str,
|
||||
_: CurrentUser,
|
||||
registry: SourceRegistryDep,
|
||||
q: str = Query(min_length=1),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
) -> ExternalSearchResponse:
|
||||
backend = registry.searchable(source) # 404 if unknown, 422 if not searchable
|
||||
if not backend.is_available():
|
||||
raise DependencyUnavailableError(f"Source {source!r} is not available.")
|
||||
results = await backend.search(q, limit=limit)
|
||||
return ExternalSearchResponse(
|
||||
results=[ExternalSearchResultOut.from_entity(r) for r in results],
|
||||
searched_sources=[source],
|
||||
)
|
||||
|
||||
@@ -63,8 +63,7 @@ async def get_storage_stats(
|
||||
by_metadata_status=stats.by_metadata_status,
|
||||
by_source=stats.by_source,
|
||||
top_genres=[
|
||||
GenreCountOut(genre=genre, track_count=count)
|
||||
for genre, count in genres[:_TOP_GENRES]
|
||||
GenreCountOut(genre=genre, track_count=count) for genre, count in genres[:_TOP_GENRES]
|
||||
],
|
||||
disk=DiskUsageOut(total=disk.total, used=disk.used, free=disk.free) if disk else None,
|
||||
)
|
||||
|
||||
@@ -87,9 +87,7 @@ async def list_tracks(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
total = await track_repo.count(
|
||||
artist_id=artist_id, album_id=album_id, q=q, source=source
|
||||
)
|
||||
total = await track_repo.count(artist_id=artist_id, album_id=album_id, q=q, source=source)
|
||||
|
||||
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})
|
||||
|
||||
Reference in New Issue
Block a user