feat(library): remote browse status + save/materialize API (§Phase2-3)
Docker Build & Publish / build (push) Successful in 1m11s
Docker Build & Publish / push (push) Failing after 6s
Docker Build & Publish / Prune old image versions (push) Has been skipped

Search results now report whether a hit is already saved (in_library,
track_id, availability). New RemoteLibraryService backs POST
/tracks/remote (idempotent placeholder save) and POST
/tracks/{id}/materialize (on-demand fetch via a new materialize_track
arq task, reusing in-flight jobs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-14 18:11:01 +03:00
parent 58b98ab5ed
commit e45e578f54
12 changed files with 723 additions and 11 deletions
+7 -2
View File
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/search", tags=["search"])
async def search(
_: CurrentUser,
registry: SourceRegistryDep,
track_repo: TrackRepoDep,
q: str = Query(min_length=1),
limit: int = Query(20, ge=1, le=50),
) -> ExternalSearchResponse:
@@ -25,7 +26,9 @@ async def search(
A source that is down contributes nothing rather than failing the whole
request (graceful degradation); only available sources are reported as
searched."""
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():
@@ -33,7 +36,9 @@ async def search(
continue
searched.append(backend.name)
hits = await backend.search(q, limit=limit)
results.extend(ExternalSearchResultOut.from_entity(h) for h in hits)
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)
+7 -5
View File
@@ -6,7 +6,7 @@ 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.deps import CurrentUser, SourceRegistryDep, SuperUser, TrackRepoDep
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
from app.domain.errors import DependencyUnavailableError
@@ -42,6 +42,7 @@ async def search_source(
source: str,
_: CurrentUser,
registry: SourceRegistryDep,
track_repo: TrackRepoDep,
q: str = Query(min_length=1),
limit: int = Query(20, ge=1, le=50),
) -> ExternalSearchResponse:
@@ -49,7 +50,8 @@ async def search_source(
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],
)
out: list[ExternalSearchResultOut] = []
for r in results:
existing = await track_repo.get_by_source(r.source, r.source_id)
out.append(ExternalSearchResultOut.from_entity(r, existing=existing))
return ExternalSearchResponse(results=out, searched_sources=[source])
+55
View File
@@ -13,14 +13,18 @@ from app.api.deps import (
CurrentUser,
FileStorageDep,
MetadataServiceDep,
RemoteLibraryServiceDep,
StreamUser,
TrackRepoDep,
)
from app.api.schemas.download import DownloadJobOut
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import (
MaterializeResponse,
MetadataApply,
MetadataMatch,
MetadataMatchesOut,
RemoteTrackSave,
TrackOut,
TrackUpdate,
)
@@ -99,6 +103,57 @@ async def list_tracks(
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.post("/remote", status_code=201)
async def save_remote_track(
body: RemoteTrackSave,
service: RemoteLibraryServiceDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
user: CurrentUser,
) -> TrackOut:
"""Save a remote browse hit (§A4 discover) as a library placeholder —
no audio is fetched yet (plan: Model C). Idempotent on ``(source,
source_id)``: saving an already-saved hit returns the existing track."""
track = await service.save_remote(
source=body.source,
source_id=body.source_id,
title=body.title,
artist=body.artist,
added_by=user.id,
)
artists = {a.id: a for a in await artist_repo.get_many([track.artist_id])}
album_ids = [track.album_id] if track.album_id else []
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out([track], artists, albums)
return items[0]
@router.post("/{track_id}/materialize")
async def materialize_track(
track_id: uuid.UUID,
service: RemoteLibraryServiceDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
user: CurrentUser,
) -> MaterializeResponse:
"""Fetch a placeholder track's audio on demand (plan: Model C lazy
materialization). Already-local tracks return ``job=None`` — nothing to
wait for. Otherwise poll ``GET /downloads/{job.id}`` until ``done``, then
stream as usual."""
outcome = await service.request_materialize(track_id, requested_by=user.id)
artists = {a.id: a for a in await artist_repo.get_many([outcome.track.artist_id])}
album_ids = [outcome.track.album_id] if outcome.track.album_id else []
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
track_out = (await _build_track_out([outcome.track], artists, albums))[0]
return MaterializeResponse(
track=track_out,
job=DownloadJobOut.from_entity(outcome.job) if outcome.job is not None else None,
)
@router.get("/{track_id}")
async def get_track(
track_id: uuid.UUID,