feat(subsonic): browsing, search, media, playlist, annotation endpoints
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

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:
Senko-san
2026-06-08 18:24:06 +03:00
parent b975164fc2
commit 551afbab13
12 changed files with 1064 additions and 70 deletions
+5
View File
@@ -17,6 +17,11 @@ JWT_SECRET=change-me-in-prod
ACCESS_TOKEN_TTL_SECONDS=900 ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_SECONDS=2592000 REFRESH_TOKEN_TTL_SECONDS=2592000
# subsonic — key that encrypts per-user Subsonic app-passwords at rest.
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
# invalidates all stored app-passwords. NOTE: /rest must be served over HTTPS.
SUBSONIC_SECRET_KEY=change-me-subsonic-key
# media / storage # media / storage
MEDIA_PATH=/data/media MEDIA_PATH=/data/media
TRANSCODE_CACHE_PATH=/data/transcode-cache TRANSCODE_CACHE_PATH=/data/transcode-cache
+34
View File
@@ -70,3 +70,37 @@ The DB URL is injected from app settings — never hardcoded in `alembic.ini`.
All settings come from environment variables (or `.env` in dev). See All settings come from environment variables (or `.env` in dev). See
[`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz) [`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz)
are **optional** — the backend degrades gracefully when they are absent. are **optional** — the backend degrades gracefully when they are absent.
## Subsonic API (`/rest`)
A Subsonic-compatible API is mounted at `/rest`, so standard clients (Symfonium,
DSub, play:Sub, …) can browse the library and stream. It is a thin adapter over
the native services — it adds no business logic of its own.
**HTTPS is mandatory.** Subsonic authentication puts the credential in the URL
(`t=md5(password+salt)&s=…`, or the legacy `p=`), so `/rest` must only ever be
exposed behind TLS (terminate at the reverse proxy). Never serve it over plain
HTTP.
### App-passwords
Subsonic auth needs a recoverable secret, but login passwords are stored as a
one-way argon2 hash. So Subsonic clients authenticate against a separate,
per-user **app-password** — high-entropy, random, and encrypted at rest with a
key derived from `SUBSONIC_SECRET_KEY` (set this to a strong random string in
prod; rotating it invalidates all stored app-passwords).
Self-service lifecycle (native API, needs a normal JWT login):
```bash
GET /api/v1/users/me/subsonic-password # reveal (generated lazily on first read)
POST /api/v1/users/me/subsonic-password # rotate
# admin, for any user:
POST /api/v1/admin/users/{user_id}/subsonic-password
```
Point the client at the instance URL, use your **username** + the revealed
**app-password** (not your login password).
> **Cover art** (`getCoverArt`) currently returns a placeholder — the cover
> pipeline (`/api/v1/.../cover` endpoints) is not implemented yet.
+89 -11
View File
@@ -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 = APIRouter()
@router.get("/star") @router.api_route("/star", methods=["GET", "POST"])
async def star() -> Any: ... @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") @router.api_route("/unstar", methods=["GET", "POST"])
async def unstar() -> Any: ... @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") @router.api_route("/setRating", methods=["GET", "POST"])
async def set_rating() -> Any: ... @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") @router.api_route("/scrobble", methods=["GET", "POST"])
async def scrobble() -> Any: ... @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
View File
@@ -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 = APIRouter()
_IGNORED_ARTICLES = "The El La Los Las Le Les"
@router.get("/getMusicFolders") _MAX_ARTISTS = 10_000 # homelab scale; one pass is fine
async def get_music_folders() -> Any: ...
@router.get("/getIndexes") async def _artists_index(artist_repo: ArtistRepoDep) -> list[dict[str, Any]]:
async def get_indexes() -> 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 _albums_for_artist(artist: Artist, album_repo: AlbumRepoDep) -> list[dict[str, Any]]:
async def get_music_directory() -> 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") @router.api_route("/getMusicFolders", methods=["GET", "POST"])
async def get_artists() -> Any: ... @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") @router.api_route("/getIndexes", methods=["GET", "POST"])
async def get_artist() -> Any: ... @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") @router.api_route("/getArtists", methods=["GET", "POST"])
async def get_album() -> Any: ... @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") @router.api_route("/getArtist", methods=["GET", "POST"])
async def get_album_list() -> Any: ... @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") @router.api_route("/getAlbum", methods=["GET", "POST"])
async def get_album_list2() -> Any: ... @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") @router.api_route("/getAlbumList", methods=["GET", "POST"])
async def get_song() -> Any: ... @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") @router.api_route("/getAlbumList2", methods=["GET", "POST"])
async def get_genres() -> Any: ... @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
View File
@@ -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 = APIRouter()
# 1x1 transparent PNG - a graceful placeholder until cover art is wired up.
@router.get("/stream") _PLACEHOLDER_PNG = base64.b64decode(
async def stream() -> Any: ... "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
)
@router.get("/download") @router.api_route("/stream", methods=["GET", "POST"])
async def download() -> Any: ... @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") @router.api_route("/download", methods=["GET", "POST"])
async def get_cover_art() -> Any: ... @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
View File
@@ -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 = APIRouter()
@router.get("/getPlaylists") def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]:
async def get_playlists() -> 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 _owned_playlist(
async def get_playlist() -> Any: ... 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 _playlist_songs(
async def create_playlist() -> Any: ... 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") @router.api_route("/getPlaylists", methods=["GET", "POST"])
async def update_playlist() -> Any: ... @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") @router.api_route("/getPlaylist", methods=["GET", "POST"])
async def delete_playlist() -> Any: ... @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
View File
@@ -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 = APIRouter()
@router.get("/search3") @router.api_route("/search3", methods=["GET", "POST"])
async def search3() -> Any: ... @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)
+92
View File
@@ -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
View File
@@ -1,15 +1,22 @@
"""Subsonic system endpoints: ping and license.""" """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 = APIRouter()
@router.get("/ping") @router.api_route("/ping", methods=["GET", "POST"])
async def ping() -> Any: ... @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") @router.api_route("/getLicense", methods=["GET", "POST"])
async def get_license() -> Any: ... @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, q: str | None,
limit: int, limit: int,
offset: int, offset: int,
sort_by: str = "title",
order: str = "asc",
) -> list[Album]: ) -> list[Album]:
stmt = select(AlbumModel) stmt = select(AlbumModel)
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(AlbumModel.artist_id == artist_id) stmt = stmt.where(AlbumModel.artist_id == artist_id)
if q: if q:
stmt = stmt.where(AlbumModel.title.ilike(f"%{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() rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows] return [_to_entity(r) for r in rows]
@@ -87,6 +87,21 @@ class SqlAlchemyTrackRepository:
await self._session.delete(row) await self._session.delete(row)
await self._session.flush() 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( async def list(
self, self,
*, *,
+236
View File
@@ -0,0 +1,236 @@
"""Integration tests for the Subsonic /rest layer (happy path per endpoint group).
Requires a reachable Postgres; skips otherwise (mirrors test_upload_stream_api).
Drives the real ASGI app: seeds a user + a track, mints a Subsonic app-password
via the native API, then exercises /rest with real query-string auth.
"""
import asyncio
import os
from collections.abc import AsyncIterator
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
from app.core.config import get_settings
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyRefreshTokenRepository,
SqlAlchemyUserRepository,
)
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.asyncio
_db_reachable_cache: bool | None = None
async def _db_reachable() -> bool:
global _db_reachable_cache
if _db_reachable_cache is not None:
return _db_reachable_cache
from sqlalchemy import text
try:
async with asyncio.timeout(3):
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
_db_reachable_cache = True
except Exception:
_db_reachable_cache = False
return _db_reachable_cache
@pytest.fixture
async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]:
if not await _db_reachable():
pytest.skip("Postgres not reachable — integration test skipped.")
os.environ["MEDIA_PATH"] = str(tmp_path)
get_settings.cache_clear()
import app.infrastructure.storage.provider as _storage_provider
_storage_provider._storage = None
try:
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
from app.application.user_service import UserService
from app.core.security import Argon2PasswordHasher
async with session_scope() as session:
await UserService(
users=SqlAlchemyUserRepository(session),
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
hasher=Argon2PasswordHasher(),
).create_user(username="testuser", password="testpass1", is_superuser=False)
from app.main import create_app
app = create_app()
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await dispose_engine()
finally:
_storage_provider._storage = None
os.environ.pop("MEDIA_PATH", None)
get_settings.cache_clear()
async def _login(api: AsyncClient) -> str:
resp = await api.post(
"/api/v1/auth/login", json={"username": "testuser", "password": "testpass1"}
)
assert resp.status_code == 200
return str(resp.json()["access_token"])
async def _subsonic_password(api: AsyncClient, token: str) -> str:
resp = await api.get(
"/api/v1/users/me/subsonic-password", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200, resp.text
return str(resp.json()["password"])
async def _seed_track(api: AsyncClient, token: str) -> str:
resp = await api.post(
"/api/v1/upload",
files={"file": ("song.mp3", b"audio bytes for subsonic" * 20, "audio/mpeg")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
return str(resp.json()["track_id"])
def _auth_params(password: str) -> dict[str, str]:
# Legacy plaintext password auth (p=) keeps the test simple; t+s is covered
# by the auth-service unit tests.
return {"u": "testuser", "p": password, "c": "pytest", "v": "1.16.1", "f": "json"}
async def _setup(api: AsyncClient) -> tuple[dict[str, str], str]:
token = await _login(api)
password = await _subsonic_password(api, token)
track_id = await _seed_track(api, token)
return _auth_params(password), track_id
async def test_ping_ok(api: AsyncClient) -> None:
params, _ = await _setup(api)
resp = await api.get("/rest/ping", params=params)
assert resp.status_code == 200
assert resp.json()["subsonic-response"]["status"] == "ok"
async def test_ping_bad_credentials_returns_code_40(api: AsyncClient) -> None:
await _setup(api)
resp = await api.get(
"/rest/ping",
params={"u": "testuser", "p": "wrong", "c": "pytest", "v": "1.16.1", "f": "json"},
)
# Subsonic errors are HTTP 200 with the failure inside the envelope.
assert resp.status_code == 200
body = resp.json()["subsonic-response"]
assert body["status"] == "failed"
assert body["error"]["code"] == 40
async def test_ping_xml_default(api: AsyncClient) -> None:
params, _ = await _setup(api)
xml_params = {k: v for k, v in params.items() if k != "f"}
resp = await api.get("/rest/ping", params=xml_params)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/xml")
root = ET.fromstring(resp.content)
assert root.attrib["status"] == "ok"
async def test_get_artists(api: AsyncClient) -> None:
params, _ = await _setup(api)
resp = await api.get("/rest/getArtists", params=params)
body = resp.json()["subsonic-response"]
assert body["status"] == "ok"
assert "artists" in body
async def test_get_album_list2(api: AsyncClient) -> None:
params, _ = await _setup(api)
resp = await api.get("/rest/getAlbumList2", params={**params, "type": "newest"})
body = resp.json()["subsonic-response"]
assert body["status"] == "ok"
assert "albumList2" in body
async def test_search3_finds_song(api: AsyncClient) -> None:
params, track_id = await _setup(api)
resp = await api.get("/rest/search3", params={**params, "query": "song"})
result = resp.json()["subsonic-response"]["searchResult3"]
song_ids = [s["id"] for s in result.get("song", [])]
assert f"tr-{track_id}" in song_ids
async def test_get_song(api: AsyncClient) -> None:
params, track_id = await _setup(api)
resp = await api.get("/rest/getSong", params={**params, "id": f"tr-{track_id}"})
song = resp.json()["subsonic-response"]["song"]
assert song["id"] == f"tr-{track_id}"
async def test_stream_returns_audio(api: AsyncClient) -> None:
params, track_id = await _setup(api)
resp = await api.get("/rest/stream", params={**params, "id": f"tr-{track_id}"})
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("audio/")
assert resp.content == b"audio bytes for subsonic" * 20
async def test_get_cover_art_placeholder(api: AsyncClient) -> None:
params, track_id = await _setup(api)
resp = await api.get("/rest/getCoverArt", params={**params, "id": f"tr-{track_id}"})
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
async def test_playlist_lifecycle(api: AsyncClient) -> None:
params, track_id = await _setup(api)
created = await api.get(
"/rest/createPlaylist", params={**params, "name": "Roadtrip", "songId": f"tr-{track_id}"}
)
playlist = created.json()["subsonic-response"]["playlist"]
assert playlist["name"] == "Roadtrip"
assert playlist["songCount"] == 1
playlist_id = playlist["id"]
listed = await api.get("/rest/getPlaylists", params=params)
names = [p["name"] for p in listed.json()["subsonic-response"]["playlists"]["playlist"]]
assert "Roadtrip" in names
deleted = await api.get("/rest/deletePlaylist", params={**params, "id": playlist_id})
assert deleted.json()["subsonic-response"]["status"] == "ok"
async def test_star_and_scrobble(api: AsyncClient) -> None:
params, track_id = await _setup(api)
star = await api.get("/rest/star", params={**params, "id": f"tr-{track_id}"})
assert star.json()["subsonic-response"]["status"] == "ok"
scrobble = await api.get(
"/rest/scrobble", params={**params, "id": f"tr-{track_id}", "submission": "true"}
)
assert scrobble.json()["subsonic-response"]["status"] == "ok"
# The like landed in the append-only log → it surfaces via the native API.
token = await _login(api)
likes = await api.get("/api/v1/likes", headers={"Authorization": f"Bearer {token}"})
assert any(item["id"] == track_id for item in likes.json()["items"])