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>
278 lines
10 KiB
Python
278 lines
10 KiB
Python
"""Subsonic browsing endpoints — thin adapters over the library repositories.
|
|
|
|
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 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()
|
|
|
|
_IGNORED_ARTICLES = "The El La Los Las Le Les"
|
|
_MAX_ARTISTS = 10_000 # homelab scale; one pass is fine
|
|
|
|
|
|
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)]
|
|
|
|
|
|
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.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.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.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.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.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.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.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.")
|