feat(subsonic): browsing, search, media, playlist, annotation endpoints
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>
This commit is contained in:
+80
-5
@@ -1,11 +1,86 @@
|
||||
"""Subsonic search endpoints."""
|
||||
"""Subsonic search endpoints — search3 over the library repositories.
|
||||
|
||||
from typing import Any
|
||||
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 fastapi import APIRouter
|
||||
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.get("/search3")
|
||||
async def search3() -> Any: ...
|
||||
@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)
|
||||
|
||||
Reference in New Issue
Block a user