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>
56 lines
2.2 KiB
Python
56 lines
2.2 KiB
Python
"""External source endpoints: enumerate sources, search, and trigger imports.
|
|
|
|
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 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
|
|
|
|
router = APIRouter(prefix="/sources", tags=["sources"])
|
|
|
|
|
|
@router.get("")
|
|
async def list_sources(_: CurrentUser, registry: SourceRegistryDep) -> list[SourceInfoOut]:
|
|
return [SourceInfoOut.from_entity(info) for info in registry.infos()]
|
|
|
|
|
|
@router.post("/{source}/scan")
|
|
async def scan_source(source: str, user: SuperUser, registry: SourceRegistryDep) -> ScanResponse:
|
|
backend = registry.indexable(source) # 404 if unknown, 422 if not indexable
|
|
if not backend.is_available():
|
|
raise DependencyUnavailableError(f"Source {source!r} is not available.")
|
|
job_id = await enqueue("scan_local_folder", source=source, added_by=str(user.id))
|
|
return ScanResponse(source=source, job_id=job_id)
|
|
|
|
|
|
@router.get("/{source}/health")
|
|
async def source_health(
|
|
source: str, _: CurrentUser, registry: SourceRegistryDep
|
|
) -> SourceHealthOut:
|
|
backend = registry.get(source) # 404 if unknown
|
|
return SourceHealthOut(name=backend.name, available=backend.is_available())
|
|
|
|
|
|
@router.get("/{source}/search")
|
|
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],
|
|
)
|