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

Thin adapters over the existing services/repositories (no business logic):

- system: ping (auth check), getLicense
- browsing: getArtists/getArtist/getAlbum, getAlbumList(2) (newest/alpha/random),
  getSong, getGenres, getMusicFolders/getIndexes/getMusicDirectory (one folder)
- search: search3 (delegates to the library repos)
- media: stream + download (reuse StreamingService, honor Range); getCoverArt
  returns a placeholder until the cover pipeline lands
- playlists: get/create/update/delete over the playlist repo (owner-scoped)
- annotation: star/unstar → append-only like log, scrobble → play history,
  setRating → clean no-op
- all endpoints also accept the .view suffix and GET+POST for client compat

Repo support: album list ordering (newest/random), track genre facets.
README documents the mandatory-HTTPS requirement and app-password workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-08 18:24:06 +03:00
parent b975164fc2
commit 551afbab13
12 changed files with 1064 additions and 70 deletions
+89 -11
View File
@@ -1,23 +1,101 @@
"""Subsonic annotation endpoints: star, rating, scrobble."""
"""Subsonic annotation endpoints: star/unstar, rating, scrobble.
from typing import Any
* ``star``/``unstar`` map to the **append-only** like event-log (a new event per
call — never a mutated boolean; CLAUDE.md invariant). Album/artist stars are
accepted but not persisted (no album/artist likes yet).
* ``scrobble`` appends to play history.
* ``setRating`` has no backing store yet — it's accepted as a clean no-op.
"""
from fastapi import APIRouter
import datetime as dt
from typing import Annotated
from fastapi import APIRouter, Query, Response
from app.api.deps import HistoryRepoDep, LikeRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
from app.api.rest.envelope import subsonic_response
from app.api.rest.ids import decode_track
from app.domain.errors import NotFoundError
router = APIRouter()
@router.get("/star")
async def star() -> Any: ...
@router.api_route("/star", methods=["GET", "POST"])
@router.api_route("/star.view", methods=["GET", "POST"])
async def star(
user: SubsonicUser,
fmt: SubsonicFormat,
like_repo: LikeRepoDep,
track_repo: TrackRepoDep,
id: Annotated[list[str] | None, Query()] = None,
albumId: Annotated[list[str] | None, Query()] = None,
artistId: Annotated[list[str] | None, Query()] = None,
) -> Response:
# albumId/artistId are accepted for client compatibility but not persisted.
for raw in id or []:
track_id = decode_track(raw)
if await track_repo.get_by_id(track_id) is None:
raise NotFoundError("Song not found.")
await like_repo.add(user_id=user.id, track_id=track_id, value="like")
return subsonic_response(fmt=fmt)
@router.get("/unstar")
async def unstar() -> Any: ...
@router.api_route("/unstar", methods=["GET", "POST"])
@router.api_route("/unstar.view", methods=["GET", "POST"])
async def unstar(
user: SubsonicUser,
fmt: SubsonicFormat,
like_repo: LikeRepoDep,
track_repo: TrackRepoDep,
id: Annotated[list[str] | None, Query()] = None,
albumId: Annotated[list[str] | None, Query()] = None,
artistId: Annotated[list[str] | None, Query()] = None,
) -> Response:
for raw in id or []:
track_id = decode_track(raw)
if await track_repo.get_by_id(track_id) is None:
raise NotFoundError("Song not found.")
await like_repo.add(user_id=user.id, track_id=track_id, value="neutral")
return subsonic_response(fmt=fmt)
@router.get("/setRating")
async def set_rating() -> Any: ...
@router.api_route("/setRating", methods=["GET", "POST"])
@router.api_route("/setRating.view", methods=["GET", "POST"])
async def set_rating(
_user: SubsonicUser,
fmt: SubsonicFormat,
id: Annotated[str, Query()],
rating: Annotated[int, Query(ge=0, le=5)],
) -> Response:
# No rating store yet — accept cleanly so clients don't error.
return subsonic_response(fmt=fmt)
@router.get("/scrobble")
async def scrobble() -> Any: ...
@router.api_route("/scrobble", methods=["GET", "POST"])
@router.api_route("/scrobble.view", methods=["GET", "POST"])
async def scrobble(
user: SubsonicUser,
fmt: SubsonicFormat,
history_repo: HistoryRepoDep,
track_repo: TrackRepoDep,
id: Annotated[list[str] | None, Query()] = None,
time: Annotated[list[int] | None, Query()] = None,
submission: Annotated[bool, Query()] = True,
) -> Response:
times = time or []
for index, raw in enumerate(id or []):
track_id = decode_track(raw)
if await track_repo.get_by_id(track_id) is None:
raise NotFoundError("Song not found.")
if index < len(times):
played_at = dt.datetime.fromtimestamp(times[index] / 1000, tz=dt.UTC)
else:
played_at = dt.datetime.now(dt.UTC)
await history_repo.add(
user_id=user.id,
track_id=track_id,
played_at=played_at,
play_duration_seconds=None,
completed=submission,
)
return subsonic_response(fmt=fmt)
+254 -24
View File
@@ -1,47 +1,277 @@
"""Subsonic browsing endpoints."""
"""Subsonic browsing endpoints — thin adapters over the library repositories.
from typing import Any
A single synthetic music folder (id ``0``) is exposed; this is a homelab, not a
multi-library server. Heavy lifting stays in the repositories; these handlers
only fan out queries and reshape rows into the Subsonic element dicts.
"""
from fastapi import APIRouter
from typing import Annotated, Any
from fastapi import APIRouter, Query, Response
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
from app.api.rest.envelope import subsonic_response
from app.api.rest.ids import IdKind, encode_album, encode_artist, parse
from app.api.rest.serializers import album_dict, artist_dict, iso, song_dict
from app.domain.entities import Album, Artist
from app.domain.errors import NotFoundError
router = APIRouter()
@router.get("/getMusicFolders")
async def get_music_folders() -> Any: ...
_IGNORED_ARTICLES = "The El La Los Las Le Les"
_MAX_ARTISTS = 10_000 # homelab scale; one pass is fine
@router.get("/getIndexes")
async def get_indexes() -> Any: ...
async def _artists_index(artist_repo: ArtistRepoDep) -> list[dict[str, Any]]:
"""Group artists into Subsonic A-Z index buckets, each with an album count."""
artists = await artist_repo.list(q=None, limit=_MAX_ARTISTS, offset=0)
buckets: dict[str, list[dict[str, Any]]] = {}
for artist in artists:
album_count = await artist_repo.album_count(artist.id)
letter = artist.name[:1].upper()
if not letter.isalpha():
letter = "#"
buckets.setdefault(letter, []).append(artist_dict(artist, album_count=album_count))
return [{"name": name, "artist": buckets[name]} for name in sorted(buckets)]
@router.get("/getMusicDirectory")
async def get_music_directory() -> Any: ...
async def _albums_for_artist(artist: Artist, album_repo: AlbumRepoDep) -> list[dict[str, Any]]:
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
counts = await album_repo.track_count_many([a.id for a in albums])
return [album_dict(a, artist, song_count=counts.get(a.id, 0)) for a in albums]
@router.get("/getArtists")
async def get_artists() -> Any: ...
@router.api_route("/getMusicFolders", methods=["GET", "POST"])
@router.api_route("/getMusicFolders.view", methods=["GET", "POST"])
async def get_music_folders(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
return subsonic_response(
{"musicFolders": {"musicFolder": [{"id": 0, "name": "Music"}]}}, fmt=fmt
)
@router.get("/getArtist")
async def get_artist() -> Any: ...
@router.api_route("/getIndexes", methods=["GET", "POST"])
@router.api_route("/getIndexes.view", methods=["GET", "POST"])
async def get_indexes(
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
) -> Response:
index = await _artists_index(artist_repo)
return subsonic_response(
{"indexes": {"ignoredArticles": _IGNORED_ARTICLES, "lastModified": 0, "index": index}},
fmt=fmt,
)
@router.get("/getAlbum")
async def get_album() -> Any: ...
@router.api_route("/getArtists", methods=["GET", "POST"])
@router.api_route("/getArtists.view", methods=["GET", "POST"])
async def get_artists(
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
) -> Response:
index = await _artists_index(artist_repo)
return subsonic_response(
{"artists": {"ignoredArticles": _IGNORED_ARTICLES, "index": index}}, fmt=fmt
)
@router.get("/getAlbumList")
async def get_album_list() -> Any: ...
@router.api_route("/getArtist", methods=["GET", "POST"])
@router.api_route("/getArtist.view", methods=["GET", "POST"])
async def get_artist(
_user: SubsonicUser,
fmt: SubsonicFormat,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
id: Annotated[str, Query()],
) -> Response:
_, artist_id = parse(id)
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError("Artist not found.")
albums = await _albums_for_artist(artist, album_repo)
payload = {
**artist_dict(artist, album_count=len(albums)),
"album": albums,
}
return subsonic_response({"artist": payload}, fmt=fmt)
@router.get("/getAlbumList2")
async def get_album_list2() -> Any: ...
@router.api_route("/getAlbum", methods=["GET", "POST"])
@router.api_route("/getAlbum.view", methods=["GET", "POST"])
async def get_album(
_user: SubsonicUser,
fmt: SubsonicFormat,
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
track_repo: TrackRepoDep,
id: Annotated[str, Query()],
) -> Response:
_, album_id = parse(id)
album = await album_repo.get_by_id(album_id)
if album is None:
raise NotFoundError("Album not found.")
artist = await artist_repo.get_by_id(album.artist_id)
tracks = await track_repo.list(
artist_id=None,
album_id=album_id,
q=None,
sort_by="title",
order="asc",
limit=500,
offset=0,
)
duration = sum(t.duration_seconds or 0 for t in tracks)
songs = [song_dict(t, artist, album) for t in tracks]
payload = {
**album_dict(album, artist, song_count=len(songs), duration=duration),
"song": songs,
}
return subsonic_response({"album": payload}, fmt=fmt)
@router.get("/getSong")
async def get_song() -> Any: ...
@router.api_route("/getAlbumList", methods=["GET", "POST"])
@router.api_route("/getAlbumList.view", methods=["GET", "POST"])
async def get_album_list(
_user: SubsonicUser,
fmt: SubsonicFormat,
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
type: Annotated[str, Query()] = "newest",
size: Annotated[int, Query(ge=1, le=500)] = 10,
offset: Annotated[int, Query(ge=0)] = 0,
) -> Response:
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
return subsonic_response({"albumList": {"album": albums}}, fmt=fmt)
@router.get("/getGenres")
async def get_genres() -> Any: ...
@router.api_route("/getAlbumList2", methods=["GET", "POST"])
@router.api_route("/getAlbumList2.view", methods=["GET", "POST"])
async def get_album_list2(
_user: SubsonicUser,
fmt: SubsonicFormat,
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
type: Annotated[str, Query()] = "newest",
size: Annotated[int, Query(ge=1, le=500)] = 10,
offset: Annotated[int, Query(ge=0)] = 0,
) -> Response:
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
return subsonic_response({"albumList2": {"album": albums}}, fmt=fmt)
async def _list_albums(
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
type_: str,
size: int,
offset: int,
) -> list[dict[str, Any]]:
if type_ == "alphabeticalByName":
sort_by, order = "title", "asc"
elif type_ == "random":
sort_by, order = "title", "random"
else: # newest / recent / frequent → newest (no play stats yet)
sort_by, order = "created", "desc"
albums = await album_repo.list(
artist_id=None, q=None, limit=size, offset=offset, sort_by=sort_by, order=order
)
return await _decorate_albums(albums, album_repo, artist_repo)
async def _decorate_albums(
albums: list[Album], album_repo: AlbumRepoDep, artist_repo: ArtistRepoDep
) -> list[dict[str, Any]]:
artist_ids = list({a.artist_id for a in albums})
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
counts = await album_repo.track_count_many([a.id for a in albums])
return [album_dict(a, artists.get(a.artist_id), song_count=counts.get(a.id, 0)) for a in albums]
@router.api_route("/getSong", methods=["GET", "POST"])
@router.api_route("/getSong.view", methods=["GET", "POST"])
async def get_song(
_user: SubsonicUser,
fmt: SubsonicFormat,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
id: Annotated[str, Query()],
) -> Response:
_, track_id = parse(id)
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError("Song not found.")
artist = await artist_repo.get_by_id(track.artist_id)
album = await album_repo.get_by_id(track.album_id) if track.album_id else None
return subsonic_response({"song": song_dict(track, artist, album)}, fmt=fmt)
@router.api_route("/getGenres", methods=["GET", "POST"])
@router.api_route("/getGenres.view", methods=["GET", "POST"])
async def get_genres(
_user: SubsonicUser, fmt: SubsonicFormat, track_repo: TrackRepoDep
) -> Response:
genres = [
{"value": name, "songCount": count, "albumCount": 0}
for name, count in await track_repo.genres()
]
return subsonic_response({"genres": {"genre": genres}}, fmt=fmt)
@router.api_route("/getMusicDirectory", methods=["GET", "POST"])
@router.api_route("/getMusicDirectory.view", methods=["GET", "POST"])
async def get_music_directory(
_user: SubsonicUser,
fmt: SubsonicFormat,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
track_repo: TrackRepoDep,
id: Annotated[str, Query()],
) -> Response:
kind, entity_id = parse(id)
if kind is IdKind.ARTIST:
artist = await artist_repo.get_by_id(entity_id)
if artist is None:
raise NotFoundError("Artist not found.")
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
counts = await album_repo.track_count_many([a.id for a in albums])
children = [
{
"id": encode_album(a.id),
"parent": encode_artist(artist.id),
"isDir": True,
"title": a.title,
"name": a.title,
"artist": artist.name,
"artistId": encode_artist(artist.id),
"coverArt": encode_album(a.id),
"songCount": counts.get(a.id, 0),
"created": iso(a.created_at),
"year": a.year,
}
for a in albums
]
directory = {"id": id, "name": artist.name, "child": children}
return subsonic_response({"directory": directory}, fmt=fmt)
if kind is IdKind.ALBUM:
album = await album_repo.get_by_id(entity_id)
if album is None:
raise NotFoundError("Album not found.")
artist = await artist_repo.get_by_id(album.artist_id)
tracks = await track_repo.list(
artist_id=None,
album_id=album.id,
q=None,
sort_by="title",
order="asc",
limit=500,
offset=0,
)
children = [song_dict(t, artist, album) for t in tracks]
directory = {
"id": id,
"parent": encode_artist(album.artist_id),
"name": album.title,
"child": children,
}
return subsonic_response({"directory": directory}, fmt=fmt)
raise NotFoundError("Directory not found.")
+69 -10
View File
@@ -1,19 +1,78 @@
"""Subsonic media endpoints: stream, download, cover art."""
"""Subsonic media endpoints: stream, download, cover art.
from typing import Any
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
"""
from fastapi import APIRouter
import base64
from typing import Annotated
from fastapi import APIRouter, Header, Query
from fastapi.responses import Response, StreamingResponse
from app.api.deps import StreamingServiceDep, SubsonicUser, TrackRepoDep
from app.api.rest.ids import decode_track, parse
from app.domain.errors import NotFoundError
router = APIRouter()
@router.get("/stream")
async def stream() -> Any: ...
# 1x1 transparent PNG - a graceful placeholder until cover art is wired up.
_PLACEHOLDER_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
)
@router.get("/download")
async def download() -> Any: ...
@router.api_route("/stream", methods=["GET", "POST"])
@router.api_route("/stream.view", methods=["GET", "POST"])
async def stream(
_user: SubsonicUser,
service: StreamingServiceDep,
id: Annotated[str, Query()],
range_header: Annotated[str | None, Header(alias="Range")] = None,
) -> StreamingResponse:
result = await service.open_stream(decode_track(id), range_header)
headers = {"Accept-Ranges": "bytes", "Content-Length": str(result.content_length)}
status_code = 200
if result.is_partial:
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
status_code = 206
return StreamingResponse(
result.stream, status_code=status_code, headers=headers, media_type=result.content_type
)
@router.get("/getCoverArt")
async def get_cover_art() -> Any: ...
@router.api_route("/download", methods=["GET", "POST"])
@router.api_route("/download.view", methods=["GET", "POST"])
async def download(
_user: SubsonicUser,
service: StreamingServiceDep,
track_repo: TrackRepoDep,
id: Annotated[str, Query()],
) -> StreamingResponse:
track_id = decode_track(id)
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError("Song not found.")
result = await service.open_stream(track_id, None)
filename = f"{track.title}.{track.file_format}"
headers = {
"Content-Length": str(result.content_length),
"Content-Disposition": f'attachment; filename="{filename}"',
}
return StreamingResponse(result.stream, headers=headers, media_type=result.content_type)
@router.api_route("/getCoverArt", methods=["GET", "POST"])
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
async def get_cover_art(
_user: SubsonicUser,
id: Annotated[str, Query()],
size: Annotated[int | None, Query()] = None,
) -> Response:
# Validate the id shape so clients get a clean error on garbage, then serve a
# placeholder. TODO: stream real covers once the cover pipeline exists.
parse(id)
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
+168 -13
View File
@@ -1,27 +1,182 @@
"""Subsonic playlist endpoints."""
"""Subsonic playlist endpoints — adapters over the playlist repository.
from typing import Any
Playlists are private to their owner (no public-playlist concept yet), so every
read/write is scoped to the authenticated user. ``createPlaylist`` doubles as a
full replace when given a ``playlistId`` (Subsonic overloads it that way).
"""
from fastapi import APIRouter
from typing import Annotated, Any
from fastapi import APIRouter, Query, Response
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
PlaylistRepoDep,
SubsonicFormat,
SubsonicUser,
)
from app.api.rest.envelope import subsonic_response
from app.api.rest.ids import decode_playlist, decode_track, encode_playlist
from app.api.rest.serializers import iso, song_dict
from app.domain.entities import Playlist, User
from app.domain.errors import NotFoundError, PermissionDeniedError
router = APIRouter()
@router.get("/getPlaylists")
async def get_playlists() -> Any: ...
def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]:
return {
"id": encode_playlist(playlist.id),
"name": playlist.name,
"comment": playlist.description,
"owner": owner,
"public": False,
"songCount": song_count,
"created": iso(playlist.created_at),
"changed": iso(playlist.updated_at),
}
@router.get("/getPlaylist")
async def get_playlist() -> Any: ...
async def _owned_playlist(
playlist_id_raw: str, playlist_repo: PlaylistRepoDep, user: User
) -> Playlist:
playlist = await playlist_repo.get_by_id(decode_playlist(playlist_id_raw))
if playlist is None:
raise NotFoundError("Playlist not found.")
if playlist.owner_id != user.id:
raise PermissionDeniedError("You don't own this playlist.")
return playlist
@router.get("/createPlaylist")
async def create_playlist() -> Any: ...
async def _playlist_songs(
playlist_id: str,
playlist_repo: PlaylistRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
) -> list[dict[str, Any]]:
tracks = await playlist_repo.get_tracks(decode_playlist(playlist_id), limit=10_000, offset=0)
artist_map = {a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))}
album_map = {
a.id: a
for a in await album_repo.get_many(
list({t.album_id for t in tracks if t.album_id is not None})
)
}
return [
song_dict(
t,
artist_map.get(t.artist_id),
album_map.get(t.album_id) if t.album_id is not None else None,
)
for t in tracks
]
@router.get("/updatePlaylist")
async def update_playlist() -> Any: ...
@router.api_route("/getPlaylists", methods=["GET", "POST"])
@router.api_route("/getPlaylists.view", methods=["GET", "POST"])
async def get_playlists(
user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep
) -> Response:
playlists = await playlist_repo.list(owner_id=user.id, limit=500, offset=0)
counts = await playlist_repo.track_count_many([p.id for p in playlists])
items = [_playlist_dict(p, user.username, song_count=counts.get(p.id, 0)) for p in playlists]
return subsonic_response({"playlists": {"playlist": items}}, fmt=fmt)
@router.get("/deletePlaylist")
async def delete_playlist() -> Any: ...
@router.api_route("/getPlaylist", methods=["GET", "POST"])
@router.api_route("/getPlaylist.view", methods=["GET", "POST"])
async def get_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
id: Annotated[str, Query()],
) -> Response:
playlist = await _owned_playlist(id, playlist_repo, user)
songs = await _playlist_songs(id, playlist_repo, artist_repo, album_repo)
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
return subsonic_response({"playlist": payload}, fmt=fmt)
@router.api_route("/createPlaylist", methods=["GET", "POST"])
@router.api_route("/createPlaylist.view", methods=["GET", "POST"])
async def create_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
name: Annotated[str | None, Query()] = None,
playlistId: Annotated[str | None, Query()] = None,
songId: Annotated[list[str] | None, Query()] = None,
) -> Response:
song_ids = [decode_track(s) for s in (songId or [])]
if playlistId is not None:
# Overloaded form: replace the existing playlist's tracks (and name).
playlist = await _owned_playlist(playlistId, playlist_repo, user)
if name is not None:
playlist = await playlist_repo.update(playlist.id, name=name, description=None)
existing = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
for t in existing:
await playlist_repo.remove_track(playlist.id, t.id)
else:
playlist = await playlist_repo.add(
name=name or "Untitled", description=None, owner_id=user.id
)
for position, track_id in enumerate(song_ids, start=1):
await playlist_repo.add_track(playlist.id, track_id, position=float(position))
encoded = encode_playlist(playlist.id)
songs = await _playlist_songs(encoded, playlist_repo, artist_repo, album_repo)
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
return subsonic_response({"playlist": payload}, fmt=fmt)
@router.api_route("/updatePlaylist", methods=["GET", "POST"])
@router.api_route("/updatePlaylist.view", methods=["GET", "POST"])
async def update_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
playlistId: Annotated[str, Query()],
name: Annotated[str | None, Query()] = None,
comment: Annotated[str | None, Query()] = None,
songIdToAdd: Annotated[list[str] | None, Query()] = None,
songIndexToRemove: Annotated[list[int] | None, Query()] = None,
) -> Response:
playlist = await _owned_playlist(playlistId, playlist_repo, user)
if name is not None or comment is not None:
playlist = await playlist_repo.update(playlist.id, name=name, description=comment)
# Removals are by index into the current ordered track list — resolve first.
if songIndexToRemove:
current = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
for index in sorted(set(songIndexToRemove)):
if 0 <= index < len(current):
await playlist_repo.remove_track(playlist.id, current[index].id)
if songIdToAdd:
position = await playlist_repo.max_position(playlist.id)
for raw in songIdToAdd:
position += 1.0
await playlist_repo.add_track(playlist.id, decode_track(raw), position=position)
return subsonic_response(fmt=fmt)
@router.api_route("/deletePlaylist", methods=["GET", "POST"])
@router.api_route("/deletePlaylist.view", methods=["GET", "POST"])
async def delete_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
id: Annotated[str, Query()],
) -> Response:
playlist = await _owned_playlist(id, playlist_repo, user)
await playlist_repo.delete(playlist.id)
return subsonic_response(fmt=fmt)
+80 -5
View File
@@ -1,11 +1,86 @@
"""Subsonic search endpoints."""
"""Subsonic search endpoints — search3 over the library repositories.
from typing import Any
Mirrors the native ``/api/v1/search/library`` fan-out (tracks/albums/artists),
reshaped into the Subsonic ``searchResult3`` element. An empty query returns
results so clients can use search3 to browse.
"""
from fastapi import APIRouter
from typing import Annotated, Any
from fastapi import APIRouter, Query, Response
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
from app.api.rest.envelope import subsonic_response
from app.api.rest.serializers import album_dict, artist_dict, song_dict
router = APIRouter()
@router.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)
+92
View File
@@ -0,0 +1,92 @@
"""Entity → Subsonic child-dict mappers (presentation only).
Pure functions turning domain entities into the attribute dicts the envelope
serializer renders as ``<artist>`` / ``<album>`` / ``<song>`` elements (or their
JSON equivalents). No business logic — they only reshape and rename.
"""
import datetime as dt
from typing import Any
from app.api.rest.ids import encode_album, encode_artist, encode_track
from app.domain.entities import Album, Artist, Track
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
# presentation detail (mirrors StreamingService's content-type negotiation).
_CONTENT_TYPE: dict[str, str] = {
"mp3": "audio/mpeg",
"flac": "audio/flac",
"m4a": "audio/mp4",
"aac": "audio/aac",
"ogg": "audio/ogg",
"opus": "audio/ogg",
"wav": "audio/wav",
"aiff": "audio/aiff",
"aif": "audio/aiff",
}
def iso(value: dt.datetime) -> str:
return value.astimezone(dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
def content_type_for(file_format: str) -> str:
return _CONTENT_TYPE.get(file_format.lower(), "application/octet-stream")
def artist_dict(artist: Artist, *, album_count: int) -> dict[str, Any]:
return {
"id": encode_artist(artist.id),
"name": artist.name,
"albumCount": album_count,
"coverArt": encode_artist(artist.id),
}
def album_dict(
album: Album,
artist: Artist | None,
*,
song_count: int,
duration: int | None = None,
) -> dict[str, Any]:
return {
"id": encode_album(album.id),
"name": album.title,
"title": album.title,
"artist": artist.name if artist is not None else None,
"artistId": encode_artist(album.artist_id),
"coverArt": encode_album(album.id),
"songCount": song_count,
"duration": duration,
"created": iso(album.created_at),
"year": album.year,
}
def song_dict(
track: Track,
artist: Artist | None,
album: Album | None,
) -> dict[str, Any]:
cover = encode_album(track.album_id) if track.album_id is not None else encode_track(track.id)
return {
"id": encode_track(track.id),
"parent": encode_album(track.album_id) if track.album_id is not None else None,
"isDir": False,
"title": track.title,
"album": album.title if album is not None else None,
"artist": artist.name if artist is not None else None,
"albumId": encode_album(track.album_id) if track.album_id is not None else None,
"artistId": encode_artist(track.artist_id),
"coverArt": cover,
"size": track.file_size,
"contentType": content_type_for(track.file_format),
"suffix": track.file_format,
"duration": track.duration_seconds,
"year": track.year,
"genre": track.genre,
"created": iso(track.created_at),
"type": "music",
"isVideo": False,
}
+13 -6
View File
@@ -1,15 +1,22 @@
"""Subsonic system endpoints: ping and license."""
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)