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:
+89
-11
@@ -1,23 +1,101 @@
|
||||
"""Subsonic annotation endpoints: star, rating, scrobble."""
|
||||
"""Subsonic annotation endpoints: star/unstar, rating, scrobble.
|
||||
|
||||
from typing import Any
|
||||
* ``star``/``unstar`` map to the **append-only** like event-log (a new event per
|
||||
call — never a mutated boolean; CLAUDE.md invariant). Album/artist stars are
|
||||
accepted but not persisted (no album/artist likes yet).
|
||||
* ``scrobble`` appends to play history.
|
||||
* ``setRating`` has no backing store yet — it's accepted as a clean no-op.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
import datetime as dt
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import HistoryRepoDep, LikeRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
from app.api.rest.ids import decode_track
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/star")
|
||||
async def star() -> Any: ...
|
||||
@router.api_route("/star", methods=["GET", "POST"])
|
||||
@router.api_route("/star.view", methods=["GET", "POST"])
|
||||
async def star(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
like_repo: LikeRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[list[str] | None, Query()] = None,
|
||||
albumId: Annotated[list[str] | None, Query()] = None,
|
||||
artistId: Annotated[list[str] | None, Query()] = None,
|
||||
) -> Response:
|
||||
# albumId/artistId are accepted for client compatibility but not persisted.
|
||||
for raw in id or []:
|
||||
track_id = decode_track(raw)
|
||||
if await track_repo.get_by_id(track_id) is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
await like_repo.add(user_id=user.id, track_id=track_id, value="like")
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/unstar")
|
||||
async def unstar() -> Any: ...
|
||||
@router.api_route("/unstar", methods=["GET", "POST"])
|
||||
@router.api_route("/unstar.view", methods=["GET", "POST"])
|
||||
async def unstar(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
like_repo: LikeRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[list[str] | None, Query()] = None,
|
||||
albumId: Annotated[list[str] | None, Query()] = None,
|
||||
artistId: Annotated[list[str] | None, Query()] = None,
|
||||
) -> Response:
|
||||
for raw in id or []:
|
||||
track_id = decode_track(raw)
|
||||
if await track_repo.get_by_id(track_id) is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
await like_repo.add(user_id=user.id, track_id=track_id, value="neutral")
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/setRating")
|
||||
async def set_rating() -> Any: ...
|
||||
@router.api_route("/setRating", methods=["GET", "POST"])
|
||||
@router.api_route("/setRating.view", methods=["GET", "POST"])
|
||||
async def set_rating(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
id: Annotated[str, Query()],
|
||||
rating: Annotated[int, Query(ge=0, le=5)],
|
||||
) -> Response:
|
||||
# No rating store yet — accept cleanly so clients don't error.
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/scrobble")
|
||||
async def scrobble() -> Any: ...
|
||||
@router.api_route("/scrobble", methods=["GET", "POST"])
|
||||
@router.api_route("/scrobble.view", methods=["GET", "POST"])
|
||||
async def scrobble(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
history_repo: HistoryRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[list[str] | None, Query()] = None,
|
||||
time: Annotated[list[int] | None, Query()] = None,
|
||||
submission: Annotated[bool, Query()] = True,
|
||||
) -> Response:
|
||||
times = time or []
|
||||
for index, raw in enumerate(id or []):
|
||||
track_id = decode_track(raw)
|
||||
if await track_repo.get_by_id(track_id) is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
if index < len(times):
|
||||
played_at = dt.datetime.fromtimestamp(times[index] / 1000, tz=dt.UTC)
|
||||
else:
|
||||
played_at = dt.datetime.now(dt.UTC)
|
||||
await history_repo.add(
|
||||
user_id=user.id,
|
||||
track_id=track_id,
|
||||
played_at=played_at,
|
||||
play_duration_seconds=None,
|
||||
completed=submission,
|
||||
)
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
+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.")
|
||||
|
||||
+69
-10
@@ -1,19 +1,78 @@
|
||||
"""Subsonic media endpoints: stream, download, cover art."""
|
||||
"""Subsonic media endpoints: stream, download, cover art.
|
||||
|
||||
from typing import Any
|
||||
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
|
||||
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
|
||||
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
|
||||
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
|
||||
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
import base64
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from app.api.deps import StreamingServiceDep, SubsonicUser, TrackRepoDep
|
||||
from app.api.rest.ids import decode_track, parse
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream() -> Any: ...
|
||||
# 1x1 transparent PNG - a graceful placeholder until cover art is wired up.
|
||||
_PLACEHOLDER_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def download() -> Any: ...
|
||||
@router.api_route("/stream", methods=["GET", "POST"])
|
||||
@router.api_route("/stream.view", methods=["GET", "POST"])
|
||||
async def stream(
|
||||
_user: SubsonicUser,
|
||||
service: StreamingServiceDep,
|
||||
id: Annotated[str, Query()],
|
||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||
) -> StreamingResponse:
|
||||
result = await service.open_stream(decode_track(id), range_header)
|
||||
headers = {"Accept-Ranges": "bytes", "Content-Length": str(result.content_length)}
|
||||
status_code = 200
|
||||
if result.is_partial:
|
||||
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
||||
status_code = 206
|
||||
return StreamingResponse(
|
||||
result.stream, status_code=status_code, headers=headers, media_type=result.content_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getCoverArt")
|
||||
async def get_cover_art() -> Any: ...
|
||||
@router.api_route("/download", methods=["GET", "POST"])
|
||||
@router.api_route("/download.view", methods=["GET", "POST"])
|
||||
async def download(
|
||||
_user: SubsonicUser,
|
||||
service: StreamingServiceDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> StreamingResponse:
|
||||
track_id = decode_track(id)
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
result = await service.open_stream(track_id, None)
|
||||
filename = f"{track.title}.{track.file_format}"
|
||||
headers = {
|
||||
"Content-Length": str(result.content_length),
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
}
|
||||
return StreamingResponse(result.stream, headers=headers, media_type=result.content_type)
|
||||
|
||||
|
||||
@router.api_route("/getCoverArt", methods=["GET", "POST"])
|
||||
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
|
||||
async def get_cover_art(
|
||||
_user: SubsonicUser,
|
||||
id: Annotated[str, Query()],
|
||||
size: Annotated[int | None, Query()] = None,
|
||||
) -> Response:
|
||||
# Validate the id shape so clients get a clean error on garbage, then serve a
|
||||
# placeholder. TODO: stream real covers once the cover pipeline exists.
|
||||
parse(id)
|
||||
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
|
||||
|
||||
+168
-13
@@ -1,27 +1,182 @@
|
||||
"""Subsonic playlist endpoints."""
|
||||
"""Subsonic playlist endpoints — adapters over the playlist repository.
|
||||
|
||||
from typing import Any
|
||||
Playlists are private to their owner (no public-playlist concept yet), so every
|
||||
read/write is scoped to the authenticated user. ``createPlaylist`` doubles as a
|
||||
full replace when given a ``playlistId`` (Subsonic overloads it that way).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
PlaylistRepoDep,
|
||||
SubsonicFormat,
|
||||
SubsonicUser,
|
||||
)
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
from app.api.rest.ids import decode_playlist, decode_track, encode_playlist
|
||||
from app.api.rest.serializers import iso, song_dict
|
||||
from app.domain.entities import Playlist, User
|
||||
from app.domain.errors import NotFoundError, PermissionDeniedError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/getPlaylists")
|
||||
async def get_playlists() -> Any: ...
|
||||
def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": encode_playlist(playlist.id),
|
||||
"name": playlist.name,
|
||||
"comment": playlist.description,
|
||||
"owner": owner,
|
||||
"public": False,
|
||||
"songCount": song_count,
|
||||
"created": iso(playlist.created_at),
|
||||
"changed": iso(playlist.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/getPlaylist")
|
||||
async def get_playlist() -> Any: ...
|
||||
async def _owned_playlist(
|
||||
playlist_id_raw: str, playlist_repo: PlaylistRepoDep, user: User
|
||||
) -> Playlist:
|
||||
playlist = await playlist_repo.get_by_id(decode_playlist(playlist_id_raw))
|
||||
if playlist is None:
|
||||
raise NotFoundError("Playlist not found.")
|
||||
if playlist.owner_id != user.id:
|
||||
raise PermissionDeniedError("You don't own this playlist.")
|
||||
return playlist
|
||||
|
||||
|
||||
@router.get("/createPlaylist")
|
||||
async def create_playlist() -> Any: ...
|
||||
async def _playlist_songs(
|
||||
playlist_id: str,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
) -> list[dict[str, Any]]:
|
||||
tracks = await playlist_repo.get_tracks(decode_playlist(playlist_id), limit=10_000, offset=0)
|
||||
artist_map = {a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))}
|
||||
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})
|
||||
)
|
||||
}
|
||||
return [
|
||||
song_dict(
|
||||
t,
|
||||
artist_map.get(t.artist_id),
|
||||
album_map.get(t.album_id) if t.album_id is not None else None,
|
||||
)
|
||||
for t in tracks
|
||||
]
|
||||
|
||||
|
||||
@router.get("/updatePlaylist")
|
||||
async def update_playlist() -> Any: ...
|
||||
@router.api_route("/getPlaylists", methods=["GET", "POST"])
|
||||
@router.api_route("/getPlaylists.view", methods=["GET", "POST"])
|
||||
async def get_playlists(
|
||||
user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep
|
||||
) -> Response:
|
||||
playlists = await playlist_repo.list(owner_id=user.id, limit=500, offset=0)
|
||||
counts = await playlist_repo.track_count_many([p.id for p in playlists])
|
||||
items = [_playlist_dict(p, user.username, song_count=counts.get(p.id, 0)) for p in playlists]
|
||||
return subsonic_response({"playlists": {"playlist": items}}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/deletePlaylist")
|
||||
async def delete_playlist() -> Any: ...
|
||||
@router.api_route("/getPlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/getPlaylist.view", methods=["GET", "POST"])
|
||||
async def get_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
playlist = await _owned_playlist(id, playlist_repo, user)
|
||||
songs = await _playlist_songs(id, playlist_repo, artist_repo, album_repo)
|
||||
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
|
||||
return subsonic_response({"playlist": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/createPlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/createPlaylist.view", methods=["GET", "POST"])
|
||||
async def create_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
name: Annotated[str | None, Query()] = None,
|
||||
playlistId: Annotated[str | None, Query()] = None,
|
||||
songId: Annotated[list[str] | None, Query()] = None,
|
||||
) -> Response:
|
||||
song_ids = [decode_track(s) for s in (songId or [])]
|
||||
|
||||
if playlistId is not None:
|
||||
# Overloaded form: replace the existing playlist's tracks (and name).
|
||||
playlist = await _owned_playlist(playlistId, playlist_repo, user)
|
||||
if name is not None:
|
||||
playlist = await playlist_repo.update(playlist.id, name=name, description=None)
|
||||
existing = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
|
||||
for t in existing:
|
||||
await playlist_repo.remove_track(playlist.id, t.id)
|
||||
else:
|
||||
playlist = await playlist_repo.add(
|
||||
name=name or "Untitled", description=None, owner_id=user.id
|
||||
)
|
||||
|
||||
for position, track_id in enumerate(song_ids, start=1):
|
||||
await playlist_repo.add_track(playlist.id, track_id, position=float(position))
|
||||
|
||||
encoded = encode_playlist(playlist.id)
|
||||
songs = await _playlist_songs(encoded, playlist_repo, artist_repo, album_repo)
|
||||
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
|
||||
return subsonic_response({"playlist": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/updatePlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/updatePlaylist.view", methods=["GET", "POST"])
|
||||
async def update_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
playlistId: Annotated[str, Query()],
|
||||
name: Annotated[str | None, Query()] = None,
|
||||
comment: Annotated[str | None, Query()] = None,
|
||||
songIdToAdd: Annotated[list[str] | None, Query()] = None,
|
||||
songIndexToRemove: Annotated[list[int] | None, Query()] = None,
|
||||
) -> Response:
|
||||
playlist = await _owned_playlist(playlistId, playlist_repo, user)
|
||||
|
||||
if name is not None or comment is not None:
|
||||
playlist = await playlist_repo.update(playlist.id, name=name, description=comment)
|
||||
|
||||
# Removals are by index into the current ordered track list — resolve first.
|
||||
if songIndexToRemove:
|
||||
current = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
|
||||
for index in sorted(set(songIndexToRemove)):
|
||||
if 0 <= index < len(current):
|
||||
await playlist_repo.remove_track(playlist.id, current[index].id)
|
||||
|
||||
if songIdToAdd:
|
||||
position = await playlist_repo.max_position(playlist.id)
|
||||
for raw in songIdToAdd:
|
||||
position += 1.0
|
||||
await playlist_repo.add_track(playlist.id, decode_track(raw), position=position)
|
||||
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/deletePlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/deletePlaylist.view", methods=["GET", "POST"])
|
||||
async def delete_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
playlist = await _owned_playlist(id, playlist_repo, user)
|
||||
await playlist_repo.delete(playlist.id)
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
+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)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Entity → Subsonic child-dict mappers (presentation only).
|
||||
|
||||
Pure functions turning domain entities into the attribute dicts the envelope
|
||||
serializer renders as ``<artist>`` / ``<album>`` / ``<song>`` elements (or their
|
||||
JSON equivalents). No business logic — they only reshape and rename.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from app.api.rest.ids import encode_album, encode_artist, encode_track
|
||||
from app.domain.entities import Album, Artist, Track
|
||||
|
||||
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
|
||||
# presentation detail (mirrors StreamingService's content-type negotiation).
|
||||
_CONTENT_TYPE: dict[str, str] = {
|
||||
"mp3": "audio/mpeg",
|
||||
"flac": "audio/flac",
|
||||
"m4a": "audio/mp4",
|
||||
"aac": "audio/aac",
|
||||
"ogg": "audio/ogg",
|
||||
"opus": "audio/ogg",
|
||||
"wav": "audio/wav",
|
||||
"aiff": "audio/aiff",
|
||||
"aif": "audio/aiff",
|
||||
}
|
||||
|
||||
|
||||
def iso(value: dt.datetime) -> str:
|
||||
return value.astimezone(dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
|
||||
|
||||
def content_type_for(file_format: str) -> str:
|
||||
return _CONTENT_TYPE.get(file_format.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
def artist_dict(artist: Artist, *, album_count: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": encode_artist(artist.id),
|
||||
"name": artist.name,
|
||||
"albumCount": album_count,
|
||||
"coverArt": encode_artist(artist.id),
|
||||
}
|
||||
|
||||
|
||||
def album_dict(
|
||||
album: Album,
|
||||
artist: Artist | None,
|
||||
*,
|
||||
song_count: int,
|
||||
duration: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": encode_album(album.id),
|
||||
"name": album.title,
|
||||
"title": album.title,
|
||||
"artist": artist.name if artist is not None else None,
|
||||
"artistId": encode_artist(album.artist_id),
|
||||
"coverArt": encode_album(album.id),
|
||||
"songCount": song_count,
|
||||
"duration": duration,
|
||||
"created": iso(album.created_at),
|
||||
"year": album.year,
|
||||
}
|
||||
|
||||
|
||||
def song_dict(
|
||||
track: Track,
|
||||
artist: Artist | None,
|
||||
album: Album | None,
|
||||
) -> dict[str, Any]:
|
||||
cover = encode_album(track.album_id) if track.album_id is not None else encode_track(track.id)
|
||||
return {
|
||||
"id": encode_track(track.id),
|
||||
"parent": encode_album(track.album_id) if track.album_id is not None else None,
|
||||
"isDir": False,
|
||||
"title": track.title,
|
||||
"album": album.title if album is not None else None,
|
||||
"artist": artist.name if artist is not None else None,
|
||||
"albumId": encode_album(track.album_id) if track.album_id is not None else None,
|
||||
"artistId": encode_artist(track.artist_id),
|
||||
"coverArt": cover,
|
||||
"size": track.file_size,
|
||||
"contentType": content_type_for(track.file_format),
|
||||
"suffix": track.file_format,
|
||||
"duration": track.duration_seconds,
|
||||
"year": track.year,
|
||||
"genre": track.genre,
|
||||
"created": iso(track.created_at),
|
||||
"type": "music",
|
||||
"isVideo": False,
|
||||
}
|
||||
+13
-6
@@ -1,15 +1,22 @@
|
||||
"""Subsonic system endpoints: ping and license."""
|
||||
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Response
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.deps import SubsonicFormat, SubsonicUser
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def ping() -> Any: ...
|
||||
@router.api_route("/ping", methods=["GET", "POST"])
|
||||
@router.api_route("/ping.view", methods=["GET", "POST"])
|
||||
async def ping(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||
# Requiring auth makes ping a credential check — exactly how clients use it.
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getLicense")
|
||||
async def get_license() -> Any: ...
|
||||
@router.api_route("/getLicense", methods=["GET", "POST"])
|
||||
@router.api_route("/getLicense.view", methods=["GET", "POST"])
|
||||
async def get_license(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||
# Self-hosted and free — the license is always valid.
|
||||
return subsonic_response({"license": {"valid": True}}, fmt=fmt)
|
||||
|
||||
@@ -76,12 +76,20 @@ class SqlAlchemyAlbumRepository:
|
||||
q: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
sort_by: str = "title",
|
||||
order: str = "asc",
|
||||
) -> list[Album]:
|
||||
stmt = select(AlbumModel)
|
||||
if artist_id is not None:
|
||||
stmt = stmt.where(AlbumModel.artist_id == artist_id)
|
||||
if q:
|
||||
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
|
||||
stmt = stmt.order_by(AlbumModel.title).limit(limit).offset(offset)
|
||||
|
||||
if order == "random":
|
||||
stmt = stmt.order_by(func.random())
|
||||
else:
|
||||
col = AlbumModel.created_at if sort_by == "created" else AlbumModel.title
|
||||
stmt = stmt.order_by(col.desc() if order == "desc" else col.asc())
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
@@ -87,6 +87,21 @@ class SqlAlchemyTrackRepository:
|
||||
await self._session.delete(row)
|
||||
await self._session.flush()
|
||||
|
||||
async def genres(self) -> list[tuple[str, int]]:
|
||||
"""Distinct non-null genres with their song counts, most common first.
|
||||
|
||||
Defined before ``list`` — the method named ``list`` shadows the builtin
|
||||
in later annotations within the class body."""
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(TrackModel.genre, func.count(TrackModel.id).label("cnt"))
|
||||
.where(TrackModel.genre.is_not(None))
|
||||
.group_by(TrackModel.genre)
|
||||
.order_by(func.count(TrackModel.id).desc())
|
||||
)
|
||||
).all()
|
||||
return [(row.genre, row.cnt) for row in rows]
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user