feat(library): remote browse status + save/materialize API (§Phase2-3)
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:
+12
-1
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.application.auth_service import AuthService
|
||||
from app.application.download_service import DownloadService
|
||||
from app.application.metadata_service import MetadataEnrichmentService
|
||||
from app.application.remote_library_service import RemoteLibraryService
|
||||
from app.application.streaming_service import StreamingService
|
||||
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||
from app.application.upload_service import UploadService
|
||||
@@ -43,7 +44,7 @@ from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
|
||||
from app.infrastructure.metadata.tags import MutagenTagReader
|
||||
from app.infrastructure.sources.registry import SourceRegistry, build_source_registry
|
||||
from app.infrastructure.storage.provider import get_file_storage
|
||||
from app.workers.queue import enqueue_download, enqueue_enrich
|
||||
from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
@@ -172,10 +173,20 @@ def get_download_service(session: SessionDep, storage: FileStorageDep) -> Downlo
|
||||
)
|
||||
|
||||
|
||||
def get_remote_library_service(session: SessionDep) -> RemoteLibraryService:
|
||||
return RemoteLibraryService(
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
artists=SqlAlchemyArtistRepository(session),
|
||||
jobs=SqlAlchemyDownloadJobRepository(session),
|
||||
enqueue_materialize=enqueue_materialize,
|
||||
)
|
||||
|
||||
|
||||
UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)]
|
||||
StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)]
|
||||
MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)]
|
||||
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
|
||||
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
|
||||
|
||||
|
||||
# -- library repository deps ---------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Schemas for searching external (fetch) sources — the §A4 discover screen."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.entities.track import Track
|
||||
from app.domain.sources import SearchResult
|
||||
|
||||
|
||||
@@ -13,9 +16,16 @@ class ExternalSearchResultOut(BaseModel):
|
||||
album: str | None
|
||||
duration_seconds: int | None
|
||||
thumbnail_url: str | None
|
||||
# Remote browse (plan: Model C) — set when this hit is already saved in the
|
||||
# library, so the UI can show "Play"/"Saved" instead of "Save to library".
|
||||
in_library: bool
|
||||
track_id: uuid.UUID | None
|
||||
availability: str | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, r: SearchResult) -> ExternalSearchResultOut:
|
||||
def from_entity(
|
||||
cls, r: SearchResult, *, existing: Track | None = None
|
||||
) -> ExternalSearchResultOut:
|
||||
return cls(
|
||||
source=r.source,
|
||||
source_id=r.source_id,
|
||||
@@ -24,6 +34,9 @@ class ExternalSearchResultOut(BaseModel):
|
||||
album=r.album,
|
||||
duration_seconds=r.duration_seconds,
|
||||
thumbnail_url=r.thumbnail_url,
|
||||
in_library=existing is not None,
|
||||
track_id=existing.id if existing is not None else None,
|
||||
availability=existing.availability if existing is not None else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.schemas.download import DownloadJobOut
|
||||
|
||||
|
||||
class TrackOut(BaseModel):
|
||||
@@ -62,3 +64,24 @@ class MetadataApply(BaseModel):
|
||||
year: int | None = None
|
||||
genre: str | None = None
|
||||
track_number: int | None = None
|
||||
|
||||
|
||||
class RemoteTrackSave(BaseModel):
|
||||
"""Save a remote browse hit (§A4 discover) as a library placeholder —
|
||||
``availability="remote"``, no audio until first play (plan: Model C)."""
|
||||
|
||||
source: str
|
||||
source_id: str = Field(min_length=1)
|
||||
title: str
|
||||
artist: str | None = None
|
||||
|
||||
|
||||
class MaterializeResponse(BaseModel):
|
||||
"""Result of requesting that a placeholder track's audio be fetched.
|
||||
|
||||
``job`` is ``None`` when the track is already ``local`` — nothing to wait
|
||||
for, the caller can stream immediately. Otherwise it's the (new or
|
||||
already in-flight) job; poll ``GET /downloads/{job.id}`` until ``done``."""
|
||||
|
||||
track: TrackOut
|
||||
job: DownloadJobOut | None
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user