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:
+254
-24
@@ -1,47 +1,277 @@
|
||||
"""Subsonic browsing endpoints."""
|
||||
"""Subsonic browsing endpoints — thin adapters over the library repositories.
|
||||
|
||||
from typing import Any
|
||||
A single synthetic music folder (id ``0``) is exposed; this is a homelab, not a
|
||||
multi-library server. Heavy lifting stays in the repositories; these handlers
|
||||
only fan out queries and reshape rows into the Subsonic element dicts.
|
||||
"""
|
||||
|
||||
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.ids import IdKind, encode_album, encode_artist, parse
|
||||
from app.api.rest.serializers import album_dict, artist_dict, iso, song_dict
|
||||
from app.domain.entities import Album, Artist
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/getMusicFolders")
|
||||
async def get_music_folders() -> Any: ...
|
||||
_IGNORED_ARTICLES = "The El La Los Las Le Les"
|
||||
_MAX_ARTISTS = 10_000 # homelab scale; one pass is fine
|
||||
|
||||
|
||||
@router.get("/getIndexes")
|
||||
async def get_indexes() -> Any: ...
|
||||
async def _artists_index(artist_repo: ArtistRepoDep) -> list[dict[str, Any]]:
|
||||
"""Group artists into Subsonic A-Z index buckets, each with an album count."""
|
||||
artists = await artist_repo.list(q=None, limit=_MAX_ARTISTS, offset=0)
|
||||
buckets: dict[str, list[dict[str, Any]]] = {}
|
||||
for artist in artists:
|
||||
album_count = await artist_repo.album_count(artist.id)
|
||||
letter = artist.name[:1].upper()
|
||||
if not letter.isalpha():
|
||||
letter = "#"
|
||||
buckets.setdefault(letter, []).append(artist_dict(artist, album_count=album_count))
|
||||
return [{"name": name, "artist": buckets[name]} for name in sorted(buckets)]
|
||||
|
||||
|
||||
@router.get("/getMusicDirectory")
|
||||
async def get_music_directory() -> Any: ...
|
||||
async def _albums_for_artist(artist: Artist, album_repo: AlbumRepoDep) -> list[dict[str, Any]]:
|
||||
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
|
||||
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
return [album_dict(a, artist, song_count=counts.get(a.id, 0)) for a in albums]
|
||||
|
||||
|
||||
@router.get("/getArtists")
|
||||
async def get_artists() -> Any: ...
|
||||
@router.api_route("/getMusicFolders", methods=["GET", "POST"])
|
||||
@router.api_route("/getMusicFolders.view", methods=["GET", "POST"])
|
||||
async def get_music_folders(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||
return subsonic_response(
|
||||
{"musicFolders": {"musicFolder": [{"id": 0, "name": "Music"}]}}, fmt=fmt
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getArtist")
|
||||
async def get_artist() -> Any: ...
|
||||
@router.api_route("/getIndexes", methods=["GET", "POST"])
|
||||
@router.api_route("/getIndexes.view", methods=["GET", "POST"])
|
||||
async def get_indexes(
|
||||
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
|
||||
) -> Response:
|
||||
index = await _artists_index(artist_repo)
|
||||
return subsonic_response(
|
||||
{"indexes": {"ignoredArticles": _IGNORED_ARTICLES, "lastModified": 0, "index": index}},
|
||||
fmt=fmt,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getAlbum")
|
||||
async def get_album() -> Any: ...
|
||||
@router.api_route("/getArtists", methods=["GET", "POST"])
|
||||
@router.api_route("/getArtists.view", methods=["GET", "POST"])
|
||||
async def get_artists(
|
||||
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
|
||||
) -> Response:
|
||||
index = await _artists_index(artist_repo)
|
||||
return subsonic_response(
|
||||
{"artists": {"ignoredArticles": _IGNORED_ARTICLES, "index": index}}, fmt=fmt
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getAlbumList")
|
||||
async def get_album_list() -> Any: ...
|
||||
@router.api_route("/getArtist", methods=["GET", "POST"])
|
||||
@router.api_route("/getArtist.view", methods=["GET", "POST"])
|
||||
async def get_artist(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
_, artist_id = parse(id)
|
||||
artist = await artist_repo.get_by_id(artist_id)
|
||||
if artist is None:
|
||||
raise NotFoundError("Artist not found.")
|
||||
albums = await _albums_for_artist(artist, album_repo)
|
||||
payload = {
|
||||
**artist_dict(artist, album_count=len(albums)),
|
||||
"album": albums,
|
||||
}
|
||||
return subsonic_response({"artist": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getAlbumList2")
|
||||
async def get_album_list2() -> Any: ...
|
||||
@router.api_route("/getAlbum", methods=["GET", "POST"])
|
||||
@router.api_route("/getAlbum.view", methods=["GET", "POST"])
|
||||
async def get_album(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
_, album_id = parse(id)
|
||||
album = await album_repo.get_by_id(album_id)
|
||||
if album is None:
|
||||
raise NotFoundError("Album not found.")
|
||||
artist = await artist_repo.get_by_id(album.artist_id)
|
||||
tracks = await track_repo.list(
|
||||
artist_id=None,
|
||||
album_id=album_id,
|
||||
q=None,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
duration = sum(t.duration_seconds or 0 for t in tracks)
|
||||
songs = [song_dict(t, artist, album) for t in tracks]
|
||||
payload = {
|
||||
**album_dict(album, artist, song_count=len(songs), duration=duration),
|
||||
"song": songs,
|
||||
}
|
||||
return subsonic_response({"album": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getSong")
|
||||
async def get_song() -> Any: ...
|
||||
@router.api_route("/getAlbumList", methods=["GET", "POST"])
|
||||
@router.api_route("/getAlbumList.view", methods=["GET", "POST"])
|
||||
async def get_album_list(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
type: Annotated[str, Query()] = "newest",
|
||||
size: Annotated[int, Query(ge=1, le=500)] = 10,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Response:
|
||||
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
|
||||
return subsonic_response({"albumList": {"album": albums}}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getGenres")
|
||||
async def get_genres() -> Any: ...
|
||||
@router.api_route("/getAlbumList2", methods=["GET", "POST"])
|
||||
@router.api_route("/getAlbumList2.view", methods=["GET", "POST"])
|
||||
async def get_album_list2(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
type: Annotated[str, Query()] = "newest",
|
||||
size: Annotated[int, Query(ge=1, le=500)] = 10,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Response:
|
||||
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
|
||||
return subsonic_response({"albumList2": {"album": albums}}, fmt=fmt)
|
||||
|
||||
|
||||
async def _list_albums(
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
type_: str,
|
||||
size: int,
|
||||
offset: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
if type_ == "alphabeticalByName":
|
||||
sort_by, order = "title", "asc"
|
||||
elif type_ == "random":
|
||||
sort_by, order = "title", "random"
|
||||
else: # newest / recent / frequent → newest (no play stats yet)
|
||||
sort_by, order = "created", "desc"
|
||||
albums = await album_repo.list(
|
||||
artist_id=None, q=None, limit=size, offset=offset, sort_by=sort_by, order=order
|
||||
)
|
||||
return await _decorate_albums(albums, album_repo, artist_repo)
|
||||
|
||||
|
||||
async def _decorate_albums(
|
||||
albums: list[Album], album_repo: AlbumRepoDep, artist_repo: ArtistRepoDep
|
||||
) -> list[dict[str, Any]]:
|
||||
artist_ids = list({a.artist_id for a in albums})
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
return [album_dict(a, artists.get(a.artist_id), song_count=counts.get(a.id, 0)) for a in albums]
|
||||
|
||||
|
||||
@router.api_route("/getSong", methods=["GET", "POST"])
|
||||
@router.api_route("/getSong.view", methods=["GET", "POST"])
|
||||
async def get_song(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
_, track_id = parse(id)
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
artist = await artist_repo.get_by_id(track.artist_id)
|
||||
album = await album_repo.get_by_id(track.album_id) if track.album_id else None
|
||||
return subsonic_response({"song": song_dict(track, artist, album)}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/getGenres", methods=["GET", "POST"])
|
||||
@router.api_route("/getGenres.view", methods=["GET", "POST"])
|
||||
async def get_genres(
|
||||
_user: SubsonicUser, fmt: SubsonicFormat, track_repo: TrackRepoDep
|
||||
) -> Response:
|
||||
genres = [
|
||||
{"value": name, "songCount": count, "albumCount": 0}
|
||||
for name, count in await track_repo.genres()
|
||||
]
|
||||
return subsonic_response({"genres": {"genre": genres}}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/getMusicDirectory", methods=["GET", "POST"])
|
||||
@router.api_route("/getMusicDirectory.view", methods=["GET", "POST"])
|
||||
async def get_music_directory(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
kind, entity_id = parse(id)
|
||||
if kind is IdKind.ARTIST:
|
||||
artist = await artist_repo.get_by_id(entity_id)
|
||||
if artist is None:
|
||||
raise NotFoundError("Artist not found.")
|
||||
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
|
||||
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
children = [
|
||||
{
|
||||
"id": encode_album(a.id),
|
||||
"parent": encode_artist(artist.id),
|
||||
"isDir": True,
|
||||
"title": a.title,
|
||||
"name": a.title,
|
||||
"artist": artist.name,
|
||||
"artistId": encode_artist(artist.id),
|
||||
"coverArt": encode_album(a.id),
|
||||
"songCount": counts.get(a.id, 0),
|
||||
"created": iso(a.created_at),
|
||||
"year": a.year,
|
||||
}
|
||||
for a in albums
|
||||
]
|
||||
directory = {"id": id, "name": artist.name, "child": children}
|
||||
return subsonic_response({"directory": directory}, fmt=fmt)
|
||||
|
||||
if kind is IdKind.ALBUM:
|
||||
album = await album_repo.get_by_id(entity_id)
|
||||
if album is None:
|
||||
raise NotFoundError("Album not found.")
|
||||
artist = await artist_repo.get_by_id(album.artist_id)
|
||||
tracks = await track_repo.list(
|
||||
artist_id=None,
|
||||
album_id=album.id,
|
||||
q=None,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
children = [song_dict(t, artist, album) for t in tracks]
|
||||
directory = {
|
||||
"id": id,
|
||||
"parent": encode_artist(album.artist_id),
|
||||
"name": album.title,
|
||||
"child": children,
|
||||
}
|
||||
return subsonic_response({"directory": directory}, fmt=fmt)
|
||||
|
||||
raise NotFoundError("Directory not found.")
|
||||
|
||||
Reference in New Issue
Block a user