"""Subsonic browsing endpoints — thin adapters over the library repositories. 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 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() _IGNORED_ARTICLES = "The El La Los Las Le Les" _MAX_ARTISTS = 10_000 # homelab scale; one pass is fine 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)] 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.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.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.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.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.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.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.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.")