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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user