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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
"""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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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"])
|
||||||
Reference in New Issue
Block a user