551afbab13
Thin adapters over the existing services/repositories (no business logic): - system: ping (auth check), getLicense - browsing: getArtists/getArtist/getAlbum, getAlbumList(2) (newest/alpha/random), getSong, getGenres, getMusicFolders/getIndexes/getMusicDirectory (one folder) - search: search3 (delegates to the library repos) - media: stream + download (reuse StreamingService, honor Range); getCoverArt returns a placeholder until the cover pipeline lands - playlists: get/create/update/delete over the playlist repo (owner-scoped) - annotation: star/unstar → append-only like log, scrobble → play history, setRating → clean no-op - all endpoints also accept the .view suffix and GET+POST for client compat Repo support: album list ordering (newest/random), track genre facets. README documents the mandatory-HTTPS requirement and app-password workflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
3.2 KiB
Python
87 lines
3.2 KiB
Python
"""Subsonic search endpoints — search3 over the library repositories.
|
|
|
|
Mirrors the native ``/api/v1/search/library`` fan-out (tracks/albums/artists),
|
|
reshaped into the Subsonic ``searchResult3`` element. An empty query returns
|
|
results so clients can use search3 to browse.
|
|
"""
|
|
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Query, Response
|
|
|
|
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
|
from app.api.rest.envelope import subsonic_response
|
|
from app.api.rest.serializers import album_dict, artist_dict, song_dict
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.api_route("/search3", methods=["GET", "POST"])
|
|
@router.api_route("/search3.view", methods=["GET", "POST"])
|
|
async def search3(
|
|
_user: SubsonicUser,
|
|
fmt: SubsonicFormat,
|
|
track_repo: TrackRepoDep,
|
|
artist_repo: ArtistRepoDep,
|
|
album_repo: AlbumRepoDep,
|
|
query: Annotated[str, Query()] = "",
|
|
artistCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
|
artistOffset: Annotated[int, Query(ge=0)] = 0,
|
|
albumCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
|
albumOffset: Annotated[int, Query(ge=0)] = 0,
|
|
songCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
|
songOffset: Annotated[int, Query(ge=0)] = 0,
|
|
) -> Response:
|
|
# Subsonic sends "" (and some clients '""') to mean "everything".
|
|
q: str | None = query.strip().strip('"') or None
|
|
|
|
artists_out: list[dict[str, Any]] = []
|
|
if artistCount:
|
|
artists = await artist_repo.list(q=q, limit=artistCount, offset=artistOffset)
|
|
for a in artists:
|
|
album_count = await artist_repo.album_count(a.id)
|
|
artists_out.append(artist_dict(a, album_count=album_count))
|
|
|
|
albums_out: list[dict[str, Any]] = []
|
|
if albumCount:
|
|
albums = await album_repo.list(artist_id=None, q=q, limit=albumCount, offset=albumOffset)
|
|
album_artist_ids = list({a.artist_id for a in albums})
|
|
album_artist_map = {a.id: a for a in await artist_repo.get_many(album_artist_ids)}
|
|
counts = await album_repo.track_count_many([a.id for a in albums])
|
|
albums_out = [
|
|
album_dict(a, album_artist_map.get(a.artist_id), song_count=counts.get(a.id, 0))
|
|
for a in albums
|
|
]
|
|
|
|
songs_out: list[dict[str, Any]] = []
|
|
if songCount:
|
|
tracks = await track_repo.list(
|
|
artist_id=None,
|
|
album_id=None,
|
|
q=q,
|
|
sort_by="title",
|
|
order="asc",
|
|
limit=songCount,
|
|
offset=songOffset,
|
|
)
|
|
song_artist_map = {
|
|
a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))
|
|
}
|
|
song_album_map = {
|
|
a.id: a
|
|
for a in await album_repo.get_many(
|
|
list({t.album_id for t in tracks if t.album_id is not None})
|
|
)
|
|
}
|
|
songs_out = [
|
|
song_dict(
|
|
t,
|
|
song_artist_map.get(t.artist_id),
|
|
song_album_map.get(t.album_id) if t.album_id is not None else None,
|
|
)
|
|
for t in tracks
|
|
]
|
|
|
|
payload = {"searchResult3": {"artist": artists_out, "album": albums_out, "song": songs_out}}
|
|
return subsonic_response(payload, fmt=fmt)
|