From 551afbab131f4943e0c79ec73a1560981180aed0 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Mon, 8 Jun 2026 18:24:06 +0300 Subject: [PATCH] feat(subsonic): browsing, search, media, playlist, annotation endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 + README.md | 34 +++ app/api/rest/annotation.py | 100 ++++++- app/api/rest/browsing.py | 278 ++++++++++++++++-- app/api/rest/media.py | 79 ++++- app/api/rest/playlists.py | 181 +++++++++++- app/api/rest/search.py | 85 +++++- app/api/rest/serializers.py | 92 ++++++ app/api/rest/system.py | 19 +- .../db/repositories/album_repository.py | 10 +- .../db/repositories/track_repository.py | 15 + tests/test_subsonic_api.py | 236 +++++++++++++++ 12 files changed, 1064 insertions(+), 70 deletions(-) create mode 100644 app/api/rest/serializers.py create mode 100644 tests/test_subsonic_api.py diff --git a/.env.example b/.env.example index 1d8be7c..9382805 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ JWT_SECRET=change-me-in-prod ACCESS_TOKEN_TTL_SECONDS=900 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_PATH=/data/media TRANSCODE_CACHE_PATH=/data/transcode-cache diff --git a/README.md b/README.md index 6a7f9ee..180d489 100644 --- a/README.md +++ b/README.md @@ -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 [`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz) 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. diff --git a/app/api/rest/annotation.py b/app/api/rest/annotation.py index 21fc85b..726898d 100644 --- a/app/api/rest/annotation.py +++ b/app/api/rest/annotation.py @@ -1,23 +1,101 @@ -"""Subsonic annotation endpoints: star, rating, scrobble.""" +"""Subsonic annotation endpoints: star/unstar, rating, scrobble. -from typing import Any +* ``star``/``unstar`` map to the **append-only** like event-log (a new event per + call — never a mutated boolean; CLAUDE.md invariant). Album/artist stars are + accepted but not persisted (no album/artist likes yet). +* ``scrobble`` appends to play history. +* ``setRating`` has no backing store yet — it's accepted as a clean no-op. +""" -from fastapi import APIRouter +import datetime as dt +from typing import Annotated + +from fastapi import APIRouter, Query, Response + +from app.api.deps import HistoryRepoDep, LikeRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep +from app.api.rest.envelope import subsonic_response +from app.api.rest.ids import decode_track +from app.domain.errors import NotFoundError router = APIRouter() -@router.get("/star") -async def star() -> Any: ... +@router.api_route("/star", methods=["GET", "POST"]) +@router.api_route("/star.view", methods=["GET", "POST"]) +async def star( + user: SubsonicUser, + fmt: SubsonicFormat, + like_repo: LikeRepoDep, + track_repo: TrackRepoDep, + id: Annotated[list[str] | None, Query()] = None, + albumId: Annotated[list[str] | None, Query()] = None, + artistId: Annotated[list[str] | None, Query()] = None, +) -> Response: + # albumId/artistId are accepted for client compatibility but not persisted. + for raw in id or []: + track_id = decode_track(raw) + if await track_repo.get_by_id(track_id) is None: + raise NotFoundError("Song not found.") + await like_repo.add(user_id=user.id, track_id=track_id, value="like") + return subsonic_response(fmt=fmt) -@router.get("/unstar") -async def unstar() -> Any: ... +@router.api_route("/unstar", methods=["GET", "POST"]) +@router.api_route("/unstar.view", methods=["GET", "POST"]) +async def unstar( + user: SubsonicUser, + fmt: SubsonicFormat, + like_repo: LikeRepoDep, + track_repo: TrackRepoDep, + id: Annotated[list[str] | None, Query()] = None, + albumId: Annotated[list[str] | None, Query()] = None, + artistId: Annotated[list[str] | None, Query()] = None, +) -> Response: + for raw in id or []: + track_id = decode_track(raw) + if await track_repo.get_by_id(track_id) is None: + raise NotFoundError("Song not found.") + await like_repo.add(user_id=user.id, track_id=track_id, value="neutral") + return subsonic_response(fmt=fmt) -@router.get("/setRating") -async def set_rating() -> Any: ... +@router.api_route("/setRating", methods=["GET", "POST"]) +@router.api_route("/setRating.view", methods=["GET", "POST"]) +async def set_rating( + _user: SubsonicUser, + fmt: SubsonicFormat, + id: Annotated[str, Query()], + rating: Annotated[int, Query(ge=0, le=5)], +) -> Response: + # No rating store yet — accept cleanly so clients don't error. + return subsonic_response(fmt=fmt) -@router.get("/scrobble") -async def scrobble() -> Any: ... +@router.api_route("/scrobble", methods=["GET", "POST"]) +@router.api_route("/scrobble.view", methods=["GET", "POST"]) +async def scrobble( + user: SubsonicUser, + fmt: SubsonicFormat, + history_repo: HistoryRepoDep, + track_repo: TrackRepoDep, + id: Annotated[list[str] | None, Query()] = None, + time: Annotated[list[int] | None, Query()] = None, + submission: Annotated[bool, Query()] = True, +) -> Response: + times = time or [] + for index, raw in enumerate(id or []): + track_id = decode_track(raw) + if await track_repo.get_by_id(track_id) is None: + raise NotFoundError("Song not found.") + if index < len(times): + played_at = dt.datetime.fromtimestamp(times[index] / 1000, tz=dt.UTC) + else: + played_at = dt.datetime.now(dt.UTC) + await history_repo.add( + user_id=user.id, + track_id=track_id, + played_at=played_at, + play_duration_seconds=None, + completed=submission, + ) + return subsonic_response(fmt=fmt) diff --git a/app/api/rest/browsing.py b/app/api/rest/browsing.py index a6be821..3af09b0 100644 --- a/app/api/rest/browsing.py +++ b/app/api/rest/browsing.py @@ -1,47 +1,277 @@ -"""Subsonic browsing endpoints.""" +"""Subsonic browsing endpoints — thin adapters over the library repositories. -from typing import Any +A single synthetic music folder (id ``0``) is exposed; this is a homelab, not a +multi-library server. Heavy lifting stays in the repositories; these handlers +only fan out queries and reshape rows into the Subsonic element dicts. +""" -from fastapi import APIRouter +from typing import Annotated, Any + +from fastapi import APIRouter, Query, Response + +from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep +from app.api.rest.envelope import subsonic_response +from app.api.rest.ids import IdKind, encode_album, encode_artist, parse +from app.api.rest.serializers import album_dict, artist_dict, iso, song_dict +from app.domain.entities import Album, Artist +from app.domain.errors import NotFoundError router = APIRouter() - -@router.get("/getMusicFolders") -async def get_music_folders() -> Any: ... +_IGNORED_ARTICLES = "The El La Los Las Le Les" +_MAX_ARTISTS = 10_000 # homelab scale; one pass is fine -@router.get("/getIndexes") -async def get_indexes() -> Any: ... +async def _artists_index(artist_repo: ArtistRepoDep) -> list[dict[str, Any]]: + """Group artists into Subsonic A-Z index buckets, each with an album count.""" + artists = await artist_repo.list(q=None, limit=_MAX_ARTISTS, offset=0) + buckets: dict[str, list[dict[str, Any]]] = {} + for artist in artists: + album_count = await artist_repo.album_count(artist.id) + letter = artist.name[:1].upper() + if not letter.isalpha(): + letter = "#" + buckets.setdefault(letter, []).append(artist_dict(artist, album_count=album_count)) + return [{"name": name, "artist": buckets[name]} for name in sorted(buckets)] -@router.get("/getMusicDirectory") -async def get_music_directory() -> Any: ... +async def _albums_for_artist(artist: Artist, album_repo: AlbumRepoDep) -> list[dict[str, Any]]: + albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0) + counts = await album_repo.track_count_many([a.id for a in albums]) + return [album_dict(a, artist, song_count=counts.get(a.id, 0)) for a in albums] -@router.get("/getArtists") -async def get_artists() -> Any: ... +@router.api_route("/getMusicFolders", methods=["GET", "POST"]) +@router.api_route("/getMusicFolders.view", methods=["GET", "POST"]) +async def get_music_folders(_user: SubsonicUser, fmt: SubsonicFormat) -> Response: + return subsonic_response( + {"musicFolders": {"musicFolder": [{"id": 0, "name": "Music"}]}}, fmt=fmt + ) -@router.get("/getArtist") -async def get_artist() -> Any: ... +@router.api_route("/getIndexes", methods=["GET", "POST"]) +@router.api_route("/getIndexes.view", methods=["GET", "POST"]) +async def get_indexes( + _user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep +) -> Response: + index = await _artists_index(artist_repo) + return subsonic_response( + {"indexes": {"ignoredArticles": _IGNORED_ARTICLES, "lastModified": 0, "index": index}}, + fmt=fmt, + ) -@router.get("/getAlbum") -async def get_album() -> Any: ... +@router.api_route("/getArtists", methods=["GET", "POST"]) +@router.api_route("/getArtists.view", methods=["GET", "POST"]) +async def get_artists( + _user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep +) -> Response: + index = await _artists_index(artist_repo) + return subsonic_response( + {"artists": {"ignoredArticles": _IGNORED_ARTICLES, "index": index}}, fmt=fmt + ) -@router.get("/getAlbumList") -async def get_album_list() -> Any: ... +@router.api_route("/getArtist", methods=["GET", "POST"]) +@router.api_route("/getArtist.view", methods=["GET", "POST"]) +async def get_artist( + _user: SubsonicUser, + fmt: SubsonicFormat, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, + id: Annotated[str, Query()], +) -> Response: + _, artist_id = parse(id) + artist = await artist_repo.get_by_id(artist_id) + if artist is None: + raise NotFoundError("Artist not found.") + albums = await _albums_for_artist(artist, album_repo) + payload = { + **artist_dict(artist, album_count=len(albums)), + "album": albums, + } + return subsonic_response({"artist": payload}, fmt=fmt) -@router.get("/getAlbumList2") -async def get_album_list2() -> Any: ... +@router.api_route("/getAlbum", methods=["GET", "POST"]) +@router.api_route("/getAlbum.view", methods=["GET", "POST"]) +async def get_album( + _user: SubsonicUser, + fmt: SubsonicFormat, + album_repo: AlbumRepoDep, + artist_repo: ArtistRepoDep, + track_repo: TrackRepoDep, + id: Annotated[str, Query()], +) -> Response: + _, album_id = parse(id) + album = await album_repo.get_by_id(album_id) + if album is None: + raise NotFoundError("Album not found.") + artist = await artist_repo.get_by_id(album.artist_id) + tracks = await track_repo.list( + artist_id=None, + album_id=album_id, + q=None, + sort_by="title", + order="asc", + limit=500, + offset=0, + ) + duration = sum(t.duration_seconds or 0 for t in tracks) + songs = [song_dict(t, artist, album) for t in tracks] + payload = { + **album_dict(album, artist, song_count=len(songs), duration=duration), + "song": songs, + } + return subsonic_response({"album": payload}, fmt=fmt) -@router.get("/getSong") -async def get_song() -> Any: ... +@router.api_route("/getAlbumList", methods=["GET", "POST"]) +@router.api_route("/getAlbumList.view", methods=["GET", "POST"]) +async def get_album_list( + _user: SubsonicUser, + fmt: SubsonicFormat, + album_repo: AlbumRepoDep, + artist_repo: ArtistRepoDep, + type: Annotated[str, Query()] = "newest", + size: Annotated[int, Query(ge=1, le=500)] = 10, + offset: Annotated[int, Query(ge=0)] = 0, +) -> Response: + albums = await _list_albums(album_repo, artist_repo, type, size, offset) + return subsonic_response({"albumList": {"album": albums}}, fmt=fmt) -@router.get("/getGenres") -async def get_genres() -> Any: ... +@router.api_route("/getAlbumList2", methods=["GET", "POST"]) +@router.api_route("/getAlbumList2.view", methods=["GET", "POST"]) +async def get_album_list2( + _user: SubsonicUser, + fmt: SubsonicFormat, + album_repo: AlbumRepoDep, + artist_repo: ArtistRepoDep, + type: Annotated[str, Query()] = "newest", + size: Annotated[int, Query(ge=1, le=500)] = 10, + offset: Annotated[int, Query(ge=0)] = 0, +) -> Response: + albums = await _list_albums(album_repo, artist_repo, type, size, offset) + return subsonic_response({"albumList2": {"album": albums}}, fmt=fmt) + + +async def _list_albums( + album_repo: AlbumRepoDep, + artist_repo: ArtistRepoDep, + type_: str, + size: int, + offset: int, +) -> list[dict[str, Any]]: + if type_ == "alphabeticalByName": + sort_by, order = "title", "asc" + elif type_ == "random": + sort_by, order = "title", "random" + else: # newest / recent / frequent → newest (no play stats yet) + sort_by, order = "created", "desc" + albums = await album_repo.list( + artist_id=None, q=None, limit=size, offset=offset, sort_by=sort_by, order=order + ) + return await _decorate_albums(albums, album_repo, artist_repo) + + +async def _decorate_albums( + albums: list[Album], album_repo: AlbumRepoDep, artist_repo: ArtistRepoDep +) -> list[dict[str, Any]]: + artist_ids = list({a.artist_id for a in albums}) + artists = {a.id: a for a in await artist_repo.get_many(artist_ids)} + counts = await album_repo.track_count_many([a.id for a in albums]) + return [album_dict(a, artists.get(a.artist_id), song_count=counts.get(a.id, 0)) for a in albums] + + +@router.api_route("/getSong", methods=["GET", "POST"]) +@router.api_route("/getSong.view", methods=["GET", "POST"]) +async def get_song( + _user: SubsonicUser, + fmt: SubsonicFormat, + track_repo: TrackRepoDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, + id: Annotated[str, Query()], +) -> Response: + _, track_id = parse(id) + track = await track_repo.get_by_id(track_id) + if track is None: + raise NotFoundError("Song not found.") + artist = await artist_repo.get_by_id(track.artist_id) + album = await album_repo.get_by_id(track.album_id) if track.album_id else None + return subsonic_response({"song": song_dict(track, artist, album)}, fmt=fmt) + + +@router.api_route("/getGenres", methods=["GET", "POST"]) +@router.api_route("/getGenres.view", methods=["GET", "POST"]) +async def get_genres( + _user: SubsonicUser, fmt: SubsonicFormat, track_repo: TrackRepoDep +) -> Response: + genres = [ + {"value": name, "songCount": count, "albumCount": 0} + for name, count in await track_repo.genres() + ] + return subsonic_response({"genres": {"genre": genres}}, fmt=fmt) + + +@router.api_route("/getMusicDirectory", methods=["GET", "POST"]) +@router.api_route("/getMusicDirectory.view", methods=["GET", "POST"]) +async def get_music_directory( + _user: SubsonicUser, + fmt: SubsonicFormat, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, + track_repo: TrackRepoDep, + id: Annotated[str, Query()], +) -> Response: + kind, entity_id = parse(id) + if kind is IdKind.ARTIST: + artist = await artist_repo.get_by_id(entity_id) + if artist is None: + raise NotFoundError("Artist not found.") + albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0) + counts = await album_repo.track_count_many([a.id for a in albums]) + children = [ + { + "id": encode_album(a.id), + "parent": encode_artist(artist.id), + "isDir": True, + "title": a.title, + "name": a.title, + "artist": artist.name, + "artistId": encode_artist(artist.id), + "coverArt": encode_album(a.id), + "songCount": counts.get(a.id, 0), + "created": iso(a.created_at), + "year": a.year, + } + for a in albums + ] + directory = {"id": id, "name": artist.name, "child": children} + return subsonic_response({"directory": directory}, fmt=fmt) + + if kind is IdKind.ALBUM: + album = await album_repo.get_by_id(entity_id) + if album is None: + raise NotFoundError("Album not found.") + artist = await artist_repo.get_by_id(album.artist_id) + tracks = await track_repo.list( + artist_id=None, + album_id=album.id, + q=None, + sort_by="title", + order="asc", + limit=500, + offset=0, + ) + children = [song_dict(t, artist, album) for t in tracks] + directory = { + "id": id, + "parent": encode_artist(album.artist_id), + "name": album.title, + "child": children, + } + return subsonic_response({"directory": directory}, fmt=fmt) + + raise NotFoundError("Directory not found.") diff --git a/app/api/rest/media.py b/app/api/rest/media.py index 4d5d658..4fcb974 100644 --- a/app/api/rest/media.py +++ b/app/api/rest/media.py @@ -1,19 +1,78 @@ -"""Subsonic media endpoints: stream, download, cover art.""" +"""Subsonic media endpoints: stream, download, cover art. -from typing import Any +``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP +Range) — they return raw bytes, not the Subsonic envelope. Transcoding params +(``maxBitRate``/``format``) are accepted but ignored; the original file is served +(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until +the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs). +""" -from fastapi import APIRouter +import base64 +from typing import Annotated + +from fastapi import APIRouter, Header, Query +from fastapi.responses import Response, StreamingResponse + +from app.api.deps import StreamingServiceDep, SubsonicUser, TrackRepoDep +from app.api.rest.ids import decode_track, parse +from app.domain.errors import NotFoundError router = APIRouter() - -@router.get("/stream") -async def stream() -> Any: ... +# 1x1 transparent PNG - a graceful placeholder until cover art is wired up. +_PLACEHOLDER_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC" +) -@router.get("/download") -async def download() -> Any: ... +@router.api_route("/stream", methods=["GET", "POST"]) +@router.api_route("/stream.view", methods=["GET", "POST"]) +async def stream( + _user: SubsonicUser, + service: StreamingServiceDep, + id: Annotated[str, Query()], + range_header: Annotated[str | None, Header(alias="Range")] = None, +) -> StreamingResponse: + result = await service.open_stream(decode_track(id), range_header) + headers = {"Accept-Ranges": "bytes", "Content-Length": str(result.content_length)} + status_code = 200 + if result.is_partial: + headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}" + status_code = 206 + return StreamingResponse( + result.stream, status_code=status_code, headers=headers, media_type=result.content_type + ) -@router.get("/getCoverArt") -async def get_cover_art() -> Any: ... +@router.api_route("/download", methods=["GET", "POST"]) +@router.api_route("/download.view", methods=["GET", "POST"]) +async def download( + _user: SubsonicUser, + service: StreamingServiceDep, + track_repo: TrackRepoDep, + id: Annotated[str, Query()], +) -> StreamingResponse: + track_id = decode_track(id) + track = await track_repo.get_by_id(track_id) + if track is None: + raise NotFoundError("Song not found.") + result = await service.open_stream(track_id, None) + filename = f"{track.title}.{track.file_format}" + headers = { + "Content-Length": str(result.content_length), + "Content-Disposition": f'attachment; filename="{filename}"', + } + return StreamingResponse(result.stream, headers=headers, media_type=result.content_type) + + +@router.api_route("/getCoverArt", methods=["GET", "POST"]) +@router.api_route("/getCoverArt.view", methods=["GET", "POST"]) +async def get_cover_art( + _user: SubsonicUser, + id: Annotated[str, Query()], + size: Annotated[int | None, Query()] = None, +) -> Response: + # Validate the id shape so clients get a clean error on garbage, then serve a + # placeholder. TODO: stream real covers once the cover pipeline exists. + parse(id) + return Response(content=_PLACEHOLDER_PNG, media_type="image/png") diff --git a/app/api/rest/playlists.py b/app/api/rest/playlists.py index 92072e9..022e9b7 100644 --- a/app/api/rest/playlists.py +++ b/app/api/rest/playlists.py @@ -1,27 +1,182 @@ -"""Subsonic playlist endpoints.""" +"""Subsonic playlist endpoints — adapters over the playlist repository. -from typing import Any +Playlists are private to their owner (no public-playlist concept yet), so every +read/write is scoped to the authenticated user. ``createPlaylist`` doubles as a +full replace when given a ``playlistId`` (Subsonic overloads it that way). +""" -from fastapi import APIRouter +from typing import Annotated, Any + +from fastapi import APIRouter, Query, Response + +from app.api.deps import ( + AlbumRepoDep, + ArtistRepoDep, + PlaylistRepoDep, + SubsonicFormat, + SubsonicUser, +) +from app.api.rest.envelope import subsonic_response +from app.api.rest.ids import decode_playlist, decode_track, encode_playlist +from app.api.rest.serializers import iso, song_dict +from app.domain.entities import Playlist, User +from app.domain.errors import NotFoundError, PermissionDeniedError router = APIRouter() -@router.get("/getPlaylists") -async def get_playlists() -> Any: ... +def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]: + return { + "id": encode_playlist(playlist.id), + "name": playlist.name, + "comment": playlist.description, + "owner": owner, + "public": False, + "songCount": song_count, + "created": iso(playlist.created_at), + "changed": iso(playlist.updated_at), + } -@router.get("/getPlaylist") -async def get_playlist() -> Any: ... +async def _owned_playlist( + playlist_id_raw: str, playlist_repo: PlaylistRepoDep, user: User +) -> Playlist: + playlist = await playlist_repo.get_by_id(decode_playlist(playlist_id_raw)) + if playlist is None: + raise NotFoundError("Playlist not found.") + if playlist.owner_id != user.id: + raise PermissionDeniedError("You don't own this playlist.") + return playlist -@router.get("/createPlaylist") -async def create_playlist() -> Any: ... +async def _playlist_songs( + playlist_id: str, + playlist_repo: PlaylistRepoDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, +) -> list[dict[str, Any]]: + tracks = await playlist_repo.get_tracks(decode_playlist(playlist_id), limit=10_000, offset=0) + artist_map = {a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))} + album_map = { + a.id: a + for a in await album_repo.get_many( + list({t.album_id for t in tracks if t.album_id is not None}) + ) + } + return [ + song_dict( + t, + artist_map.get(t.artist_id), + album_map.get(t.album_id) if t.album_id is not None else None, + ) + for t in tracks + ] -@router.get("/updatePlaylist") -async def update_playlist() -> Any: ... +@router.api_route("/getPlaylists", methods=["GET", "POST"]) +@router.api_route("/getPlaylists.view", methods=["GET", "POST"]) +async def get_playlists( + user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep +) -> Response: + playlists = await playlist_repo.list(owner_id=user.id, limit=500, offset=0) + counts = await playlist_repo.track_count_many([p.id for p in playlists]) + items = [_playlist_dict(p, user.username, song_count=counts.get(p.id, 0)) for p in playlists] + return subsonic_response({"playlists": {"playlist": items}}, fmt=fmt) -@router.get("/deletePlaylist") -async def delete_playlist() -> Any: ... +@router.api_route("/getPlaylist", methods=["GET", "POST"]) +@router.api_route("/getPlaylist.view", methods=["GET", "POST"]) +async def get_playlist( + user: SubsonicUser, + fmt: SubsonicFormat, + playlist_repo: PlaylistRepoDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, + id: Annotated[str, Query()], +) -> Response: + playlist = await _owned_playlist(id, playlist_repo, user) + songs = await _playlist_songs(id, playlist_repo, artist_repo, album_repo) + payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs} + return subsonic_response({"playlist": payload}, fmt=fmt) + + +@router.api_route("/createPlaylist", methods=["GET", "POST"]) +@router.api_route("/createPlaylist.view", methods=["GET", "POST"]) +async def create_playlist( + user: SubsonicUser, + fmt: SubsonicFormat, + playlist_repo: PlaylistRepoDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, + name: Annotated[str | None, Query()] = None, + playlistId: Annotated[str | None, Query()] = None, + songId: Annotated[list[str] | None, Query()] = None, +) -> Response: + song_ids = [decode_track(s) for s in (songId or [])] + + if playlistId is not None: + # Overloaded form: replace the existing playlist's tracks (and name). + playlist = await _owned_playlist(playlistId, playlist_repo, user) + if name is not None: + playlist = await playlist_repo.update(playlist.id, name=name, description=None) + existing = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0) + for t in existing: + await playlist_repo.remove_track(playlist.id, t.id) + else: + playlist = await playlist_repo.add( + name=name or "Untitled", description=None, owner_id=user.id + ) + + for position, track_id in enumerate(song_ids, start=1): + await playlist_repo.add_track(playlist.id, track_id, position=float(position)) + + encoded = encode_playlist(playlist.id) + songs = await _playlist_songs(encoded, playlist_repo, artist_repo, album_repo) + payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs} + return subsonic_response({"playlist": payload}, fmt=fmt) + + +@router.api_route("/updatePlaylist", methods=["GET", "POST"]) +@router.api_route("/updatePlaylist.view", methods=["GET", "POST"]) +async def update_playlist( + user: SubsonicUser, + fmt: SubsonicFormat, + playlist_repo: PlaylistRepoDep, + playlistId: Annotated[str, Query()], + name: Annotated[str | None, Query()] = None, + comment: Annotated[str | None, Query()] = None, + songIdToAdd: Annotated[list[str] | None, Query()] = None, + songIndexToRemove: Annotated[list[int] | None, Query()] = None, +) -> Response: + playlist = await _owned_playlist(playlistId, playlist_repo, user) + + if name is not None or comment is not None: + playlist = await playlist_repo.update(playlist.id, name=name, description=comment) + + # Removals are by index into the current ordered track list — resolve first. + if songIndexToRemove: + current = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0) + for index in sorted(set(songIndexToRemove)): + if 0 <= index < len(current): + await playlist_repo.remove_track(playlist.id, current[index].id) + + if songIdToAdd: + position = await playlist_repo.max_position(playlist.id) + for raw in songIdToAdd: + position += 1.0 + await playlist_repo.add_track(playlist.id, decode_track(raw), position=position) + + return subsonic_response(fmt=fmt) + + +@router.api_route("/deletePlaylist", methods=["GET", "POST"]) +@router.api_route("/deletePlaylist.view", methods=["GET", "POST"]) +async def delete_playlist( + user: SubsonicUser, + fmt: SubsonicFormat, + playlist_repo: PlaylistRepoDep, + id: Annotated[str, Query()], +) -> Response: + playlist = await _owned_playlist(id, playlist_repo, user) + await playlist_repo.delete(playlist.id) + return subsonic_response(fmt=fmt) diff --git a/app/api/rest/search.py b/app/api/rest/search.py index 5a61ae5..6f00367 100644 --- a/app/api/rest/search.py +++ b/app/api/rest/search.py @@ -1,11 +1,86 @@ -"""Subsonic search endpoints.""" +"""Subsonic search endpoints — search3 over the library repositories. -from typing import Any +Mirrors the native ``/api/v1/search/library`` fan-out (tracks/albums/artists), +reshaped into the Subsonic ``searchResult3`` element. An empty query returns +results so clients can use search3 to browse. +""" -from fastapi import APIRouter +from typing import Annotated, Any + +from fastapi import APIRouter, Query, Response + +from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep +from app.api.rest.envelope import subsonic_response +from app.api.rest.serializers import album_dict, artist_dict, song_dict router = APIRouter() -@router.get("/search3") -async def search3() -> Any: ... +@router.api_route("/search3", methods=["GET", "POST"]) +@router.api_route("/search3.view", methods=["GET", "POST"]) +async def search3( + _user: SubsonicUser, + fmt: SubsonicFormat, + track_repo: TrackRepoDep, + artist_repo: ArtistRepoDep, + album_repo: AlbumRepoDep, + query: Annotated[str, Query()] = "", + artistCount: Annotated[int, Query(ge=0, le=500)] = 20, + artistOffset: Annotated[int, Query(ge=0)] = 0, + albumCount: Annotated[int, Query(ge=0, le=500)] = 20, + albumOffset: Annotated[int, Query(ge=0)] = 0, + songCount: Annotated[int, Query(ge=0, le=500)] = 20, + songOffset: Annotated[int, Query(ge=0)] = 0, +) -> Response: + # Subsonic sends "" (and some clients '""') to mean "everything". + q: str | None = query.strip().strip('"') or None + + artists_out: list[dict[str, Any]] = [] + if artistCount: + artists = await artist_repo.list(q=q, limit=artistCount, offset=artistOffset) + for a in artists: + album_count = await artist_repo.album_count(a.id) + artists_out.append(artist_dict(a, album_count=album_count)) + + albums_out: list[dict[str, Any]] = [] + if albumCount: + albums = await album_repo.list(artist_id=None, q=q, limit=albumCount, offset=albumOffset) + album_artist_ids = list({a.artist_id for a in albums}) + album_artist_map = {a.id: a for a in await artist_repo.get_many(album_artist_ids)} + counts = await album_repo.track_count_many([a.id for a in albums]) + albums_out = [ + album_dict(a, album_artist_map.get(a.artist_id), song_count=counts.get(a.id, 0)) + for a in albums + ] + + songs_out: list[dict[str, Any]] = [] + if songCount: + tracks = await track_repo.list( + artist_id=None, + album_id=None, + q=q, + sort_by="title", + order="asc", + limit=songCount, + offset=songOffset, + ) + song_artist_map = { + a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks})) + } + song_album_map = { + a.id: a + for a in await album_repo.get_many( + list({t.album_id for t in tracks if t.album_id is not None}) + ) + } + songs_out = [ + song_dict( + t, + song_artist_map.get(t.artist_id), + song_album_map.get(t.album_id) if t.album_id is not None else None, + ) + for t in tracks + ] + + payload = {"searchResult3": {"artist": artists_out, "album": albums_out, "song": songs_out}} + return subsonic_response(payload, fmt=fmt) diff --git a/app/api/rest/serializers.py b/app/api/rest/serializers.py new file mode 100644 index 0000000..5b6f99e --- /dev/null +++ b/app/api/rest/serializers.py @@ -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 ```` / ```` / ```` 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, + } diff --git a/app/api/rest/system.py b/app/api/rest/system.py index 10a8e38..6a5cb6a 100644 --- a/app/api/rest/system.py +++ b/app/api/rest/system.py @@ -1,15 +1,22 @@ """Subsonic system endpoints: ping and license.""" -from typing import Any +from fastapi import APIRouter, Response -from fastapi import APIRouter +from app.api.deps import SubsonicFormat, SubsonicUser +from app.api.rest.envelope import subsonic_response router = APIRouter() -@router.get("/ping") -async def ping() -> Any: ... +@router.api_route("/ping", methods=["GET", "POST"]) +@router.api_route("/ping.view", methods=["GET", "POST"]) +async def ping(_user: SubsonicUser, fmt: SubsonicFormat) -> Response: + # Requiring auth makes ping a credential check — exactly how clients use it. + return subsonic_response(fmt=fmt) -@router.get("/getLicense") -async def get_license() -> Any: ... +@router.api_route("/getLicense", methods=["GET", "POST"]) +@router.api_route("/getLicense.view", methods=["GET", "POST"]) +async def get_license(_user: SubsonicUser, fmt: SubsonicFormat) -> Response: + # Self-hosted and free — the license is always valid. + return subsonic_response({"license": {"valid": True}}, fmt=fmt) diff --git a/app/infrastructure/db/repositories/album_repository.py b/app/infrastructure/db/repositories/album_repository.py index d096fad..e811a15 100644 --- a/app/infrastructure/db/repositories/album_repository.py +++ b/app/infrastructure/db/repositories/album_repository.py @@ -76,12 +76,20 @@ class SqlAlchemyAlbumRepository: q: str | None, limit: int, offset: int, + sort_by: str = "title", + order: str = "asc", ) -> list[Album]: stmt = select(AlbumModel) if artist_id is not None: stmt = stmt.where(AlbumModel.artist_id == artist_id) if q: stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%")) - stmt = stmt.order_by(AlbumModel.title).limit(limit).offset(offset) + + if order == "random": + stmt = stmt.order_by(func.random()) + else: + col = AlbumModel.created_at if sort_by == "created" else AlbumModel.title + stmt = stmt.order_by(col.desc() if order == "desc" else col.asc()) + stmt = stmt.limit(limit).offset(offset) rows = (await self._session.execute(stmt)).scalars().all() return [_to_entity(r) for r in rows] diff --git a/app/infrastructure/db/repositories/track_repository.py b/app/infrastructure/db/repositories/track_repository.py index 665ddd9..8f4a41d 100644 --- a/app/infrastructure/db/repositories/track_repository.py +++ b/app/infrastructure/db/repositories/track_repository.py @@ -87,6 +87,21 @@ class SqlAlchemyTrackRepository: await self._session.delete(row) await self._session.flush() + async def genres(self) -> list[tuple[str, int]]: + """Distinct non-null genres with their song counts, most common first. + + Defined before ``list`` — the method named ``list`` shadows the builtin + in later annotations within the class body.""" + rows = ( + await self._session.execute( + select(TrackModel.genre, func.count(TrackModel.id).label("cnt")) + .where(TrackModel.genre.is_not(None)) + .group_by(TrackModel.genre) + .order_by(func.count(TrackModel.id).desc()) + ) + ).all() + return [(row.genre, row.cnt) for row in rows] + async def list( self, *, diff --git a/tests/test_subsonic_api.py b/tests/test_subsonic_api.py new file mode 100644 index 0000000..f42326b --- /dev/null +++ b/tests/test_subsonic_api.py @@ -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"])