Files
bonob/src/subsonic.ts
simojenki 6ad39ce044 refactor
2022-04-24 10:35:50 +10:00

1055 lines
27 KiB
TypeScript

import { option as O, taskEither as TE } from "fp-ts";
import * as A from "fp-ts/Array";
import { ordString } from "fp-ts/lib/Ord";
import { pipe } from "fp-ts/lib/function";
import { Md5 } from "ts-md5/dist/md5";
import {
Credentials,
MusicService,
Album,
Result,
slice2,
AlbumQuery,
ArtistQuery,
MusicLibrary,
AlbumSummary,
Genre,
Track,
CoverArt,
Rating,
AlbumQueryType,
Artist,
AuthFailure,
Sortable,
} from "./music_service";
import sharp from "sharp";
import _ from "underscore";
import fse from "fs-extra";
import path from "path";
import axios, { AxiosRequestConfig } from "axios";
import randomstring from "randomstring";
import { b64Encode, b64Decode } from "./b64";
import logger from "./logger";
import { assertSystem, BUrn } from "./burn";
import { artist } from "./smapi";
export const BROWSER_HEADERS = {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"accept-encoding": "gzip, deflate, br",
"accept-language": "en-GB,en;q=0.5",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
};
export const t = (password: string, s: string) =>
Md5.hashStr(`${password}${s}`);
export const t_and_s = (password: string) => {
const s = randomstring.generate();
return {
t: t(password, s),
s,
};
};
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
export const isValidImage = (url: string | undefined) =>
url != undefined && !url.endsWith(DODGY_IMAGE_NAME);
type SubsonicEnvelope = {
"subsonic-response": SubsonicResponse;
};
type SubsonicResponse = {
status: string;
};
type album = {
id: string;
name: string;
artist: string | undefined;
artistId: string | undefined;
coverArt: string | undefined;
genre: string | undefined;
year: string | undefined;
};
type artist = {
id: string;
name: string;
albumCount: number;
artistImageUrl: string | undefined;
};
type GetArtistsResponse = SubsonicResponse & {
artists: {
index: {
artist: artist[];
name: string;
}[];
};
};
type GetAlbumListResponse = SubsonicResponse & {
albumList2: {
album: album[];
};
};
type genre = {
songCount: number;
albumCount: number;
value: string;
};
type GetGenresResponse = SubsonicResponse & {
genres: {
genre: genre[];
};
};
type SubsonicError = SubsonicResponse & {
error: {
code: string;
message: string;
};
};
export type images = {
smallImageUrl: string | undefined;
mediumImageUrl: string | undefined;
largeImageUrl: string | undefined;
};
type artistInfo = images & {
biography: string | undefined;
musicBrainzId: string | undefined;
lastFmUrl: string | undefined;
similarArtist: artist[];
};
type ArtistSummary = IdName & {
image: BUrn | undefined;
};
type GetArtistInfoResponse = SubsonicResponse & {
artistInfo2: artistInfo;
};
type GetArtistResponse = SubsonicResponse & {
artist: artist & {
album: album[];
};
};
export type song = {
id: string;
parent: string | undefined;
title: string;
album: string | undefined;
albumId: string | undefined;
artist: string | undefined;
artistId: string | undefined;
track: number | undefined;
year: string | undefined;
genre: string | undefined;
coverArt: string | undefined;
created: string | undefined;
duration: number | undefined;
bitRate: number | undefined;
suffix: string | undefined;
contentType: string | undefined;
type: string | undefined;
userRating: number | undefined;
starred: string | undefined;
};
type GetAlbumResponse = {
album: album & {
song: song[];
};
};
type playlist = {
id: string;
name: string;
};
type GetPlaylistResponse = {
playlist: {
id: string;
name: string;
entry: song[];
};
};
type GetPlaylistsResponse = {
playlists: { playlist: playlist[] };
};
type GetSimilarSongsResponse = {
similarSongs2: { song: song[] };
};
type GetTopSongsResponse = {
topSongs: { song: song[] };
};
type GetSongResponse = {
song: song;
};
export type PingResponse = {
status: string;
version: string;
type: string;
serverVersion: string;
};
type Search3Response = SubsonicResponse & {
searchResult3: {
artist: artist[];
album: album[];
song: song[];
};
};
export function isError(
subsonicResponse: SubsonicResponse
): subsonicResponse is SubsonicError {
return (subsonicResponse as SubsonicError).error !== undefined;
}
export type NDArtist = {
id: string;
name: string;
orderArtistName: string | undefined;
largeImageUrl: string | undefined;
};
type IdName = {
id: string;
name: string;
};
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
pipe(
coverArt,
O.fromNullable,
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
O.getOrElseW(() => undefined)
);
export const artistSummaryFromNDArtist = (
artist: NDArtist
): ArtistSummary & Sortable => ({
id: artist.id,
name: artist.name,
sortName: artist.orderArtistName || artist.name,
image: artistImageURN({
artistId: artist.id,
artistImageURL: artist.largeImageUrl,
}),
});
export const artistImageURN = (
spec: Partial<{
artistId: string | undefined;
artistImageURL: string | undefined;
}>
): BUrn | undefined => {
const deets = {
artistId: undefined,
artistImageURL: undefined,
...spec,
};
if (deets.artistImageURL && isValidImage(deets.artistImageURL)) {
return {
system: "external",
resource: deets.artistImageURL,
};
} else if (artistIsInLibrary(deets.artistId)) {
return {
system: "subsonic",
resource: `art:${deets.artistId!}`,
};
} else {
return undefined;
}
};
export const asTrack = (album: Album, song: song): Track => ({
id: song.id,
name: song.title,
mimeType: song.contentType!,
duration: song.duration || 0,
number: song.track || 0,
genre: maybeAsGenre(song.genre),
coverArt: coverArtURN(song.coverArt),
album,
artist: {
id: song.artistId,
name: song.artist ? song.artist : "?",
image: song.artistId
? artistImageURN({ artistId: song.artistId })
: undefined,
},
rating: {
love: song.starred != undefined,
stars:
song.userRating && song.userRating <= 5 && song.userRating >= 0
? song.userRating
: 0,
},
});
const asAlbum = (album: album): Album => ({
id: album.id,
name: album.name,
year: album.year,
genre: maybeAsGenre(album.genre),
artistId: album.artistId,
artistName: album.artist,
coverArt: coverArtURN(album.coverArt),
});
export const asGenre = (genreName: string) => ({
id: b64Encode(genreName),
name: genreName,
});
const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
pipe(
genreName,
O.fromNullable,
O.map(asGenre),
O.getOrElseW(() => undefined)
);
export type StreamClientApplication = (track: Track) => string;
const DEFAULT_CLIENT_APPLICATION = "bonob";
const USER_AGENT = "bonob";
export const DEFAULT: StreamClientApplication = (_: Track) =>
DEFAULT_CLIENT_APPLICATION;
export function appendMimeTypeToClientFor(mimeTypes: string[]) {
return (track: Track) =>
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
}
export const asURLSearchParams = (q: any) => {
const urlSearchParams = new URLSearchParams();
Object.keys(q).forEach((k) => {
_.flatten([q[k]]).forEach((v) => {
urlSearchParams.append(k, `${v}`);
});
});
return urlSearchParams;
};
export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
export const cachingImageFetcher =
(cacheDir: string, delegate: ImageFetcher) =>
async (url: string): Promise<CoverArt | undefined> => {
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
return fse
.readFile(filename)
.then((data) => ({ contentType: "image/png", data }))
.catch(() =>
delegate(url).then((image) => {
if (image) {
return sharp(image.data)
.png()
.toBuffer()
.then((png) => {
return fse
.writeFile(filename, png)
.then(() => ({ contentType: "image/png", data: png }));
});
} else {
return undefined;
}
})
);
};
export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> =>
axios
.get(url, {
headers: BROWSER_HEADERS,
responseType: "arraybuffer",
})
.then((res) => ({
contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"),
}))
.catch(() => undefined);
const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
alphabeticalByArtist: "alphabeticalByArtist",
alphabeticalByName: "alphabeticalByName",
byGenre: "byGenre",
random: "random",
recentlyPlayed: "recent",
mostPlayed: "frequent",
recentlyAdded: "newest",
favourited: "starred",
starred: "highest",
};
const artistIsInLibrary = (artistId: string | undefined) =>
artistId != undefined && artistId != "-1";
export type SubsonicCredentials = Credentials & {
type: string;
bearer: string | undefined;
};
export const asToken = (credentials: SubsonicCredentials) =>
b64Encode(JSON.stringify(credentials));
export const parseToken = (token: string): SubsonicCredentials =>
JSON.parse(b64Decode(token));
interface SubsonicMusicLibrary extends MusicLibrary {
flavour(): string;
bearerToken(
credentials: Credentials
): TE.TaskEither<Error, string | undefined>;
}
export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
subsonic: Subsonic;
credentials: SubsonicCredentials;
constructor(subsonic: Subsonic, credentials: SubsonicCredentials) {
this.subsonic = subsonic;
this.credentials = credentials;
}
flavour = () => "subsonic";
bearerToken = (_: Credentials) => TE.right(undefined);
artists = (q: ArtistQuery): Promise<Result<ArtistSummary & Sortable>> =>
this.getArtists()
.then(slice2(q))
.then(([page, total]) => ({
total,
results: page.map((it) => ({
id: it.id,
name: it.name,
sortName: it.name,
image: it.image,
})),
}));
artist = async (id: string): Promise<Artist> =>
this.getArtistWithInfo(id);
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
this.getAlbumList2(q);
album = (id: string): Promise<Album> => this.getAlbum(id);
genres = () =>
this.subsonic
.getJSON<GetGenresResponse>(this.credentials, "/rest/getGenres")
.then((it) =>
pipe(
it.genres.genre || [],
A.filter((it) => it.albumCount > 0),
A.map((it) => it.value),
A.sort(ordString),
A.map((it) => ({ id: b64Encode(it), name: it }))
)
);
tracks = (albumId: string) =>
this.subsonic
.getJSON<GetAlbumResponse>(this.credentials, "/rest/getAlbum", {
id: albumId,
})
.then((it) => it.album)
.then((album) =>
(album.song || []).map((song) => asTrack(asAlbum(album), song))
);
track = (trackId: string) => this.getTrack(trackId);
rate = (trackId: string, rating: Rating) =>
Promise.resolve(true)
.then(() => {
if (rating.stars >= 0 && rating.stars <= 5) {
return this.getTrack(trackId);
} else {
throw `Invalid rating.stars value of ${rating.stars}`;
}
})
.then((track) => {
const thingsToUpdate = [];
if (track.rating.love != rating.love) {
thingsToUpdate.push(
this.subsonic.getJSON(
this.credentials,
`/rest/${rating.love ? "star" : "unstar"}`,
{
id: trackId,
}
)
);
}
if (track.rating.stars != rating.stars) {
thingsToUpdate.push(
this.subsonic.getJSON(this.credentials, `/rest/setRating`, {
id: trackId,
rating: rating.stars,
})
);
}
return Promise.all(thingsToUpdate);
})
.then(() => true)
.catch(() => false);
stream = async ({
trackId,
range,
}: {
trackId: string;
range: string | undefined;
}) =>
this.getTrack(trackId).then((track) =>
this.subsonic
.get(
this.credentials,
`/rest/stream`,
{
id: trackId,
c: this.subsonic.streamClientApplication(track),
},
{
headers: pipe(
range,
O.fromNullable,
O.map((range) => ({
"User-Agent": USER_AGENT,
Range: range,
})),
O.getOrElse(() => ({
"User-Agent": USER_AGENT,
}))
),
responseType: "stream",
}
)
.then((res) => ({
status: res.status,
headers: {
"content-type": res.headers["content-type"],
"content-length": res.headers["content-length"],
"content-range": res.headers["content-range"],
"accept-ranges": res.headers["accept-ranges"],
},
stream: res.data,
}))
);
coverArt = async (coverArtURN: BUrn, size?: number) =>
Promise.resolve(coverArtURN)
.then((it) => assertSystem(it, "subsonic"))
.then((it) => it.resource.split(":")[1]!)
.then((it) => this.getCoverArt(this.credentials, it, size))
.then((res) => ({
contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"),
}))
.catch((e) => {
logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`);
return undefined;
});
scrobble = async (id: string) =>
this.subsonic
.getJSON(this.credentials, `/rest/scrobble`, {
id,
submission: true,
})
.then((_) => true)
.catch(() => false);
nowPlaying = async (id: string) =>
this.subsonic
.getJSON(this.credentials, `/rest/scrobble`, {
id,
submission: false,
})
.then((_) => true)
.catch(() => false);
searchArtists = async (query: string) =>
this.search3({ query, artistCount: 20 }).then(
({ artists }) =>
artists.map((artist) => ({
id: artist.id,
name: artist.name,
image: artistImageURN({
artistId: artist.id,
artistImageURL: artist.artistImageUrl,
}),
}))
);
searchAlbums = async (query: string) =>
this.search3({ query, albumCount: 20 }).then(
({ albums }) => this.toAlbumSummary(albums)
);
searchTracks = async (query: string) =>
this.search3({ query, songCount: 20 }).then(({ songs }) =>
Promise.all(songs.map((it) => this.getTrack(it.id)))
);
playlists = async () =>
this.subsonic
.getJSON<GetPlaylistsResponse>(this.credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || [])
.then((playlists) =>
playlists.map((it) => ({ id: it.id, name: it.name }))
);
playlist = async (id: string) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/getPlaylist", {
id,
})
.then((it) => it.playlist)
.then((playlist) => {
let trackNumber = 1;
return {
id: playlist.id,
name: playlist.name,
entries: (playlist.entry || []).map((entry) => ({
...asTrack(
{
id: entry.albumId!,
name: entry.album!,
year: entry.year,
genre: maybeAsGenre(entry.genre),
artistName: entry.artist,
artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt),
},
entry
),
number: trackNumber++,
})),
};
});
createPlaylist = async (name: string) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/createPlaylist", {
name,
})
.then((it) => it.playlist)
.then((it) => ({ id: it.id, name: it.name }));
deletePlaylist = async (id: string) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/deletePlaylist", {
id,
})
.then((_) => true);
addToPlaylist = async (playlistId: string, trackId: string) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
playlistId,
songIdToAdd: trackId,
})
.then((_) => true);
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
this.subsonic
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
playlistId,
songIndexToRemove: indicies,
})
.then((_) => true);
similarSongs = async (id: string) =>
this.subsonic
.getJSON<GetSimilarSongsResponse>(
this.credentials,
"/rest/getSimilarSongs2",
{ id, count: 50 }
)
.then((it) => it.similarSongs2.song || [])
.then((songs) =>
Promise.all(
songs.map((song) =>
this.getAlbum(song.albumId!).then((album) =>
asTrack(album, song)
)
)
)
);
topSongs = async (artistId: string) =>
this.getArtist(artistId).then(({ name }) =>
this.subsonic
.getJSON<GetTopSongsResponse>(this.credentials, "/rest/getTopSongs", {
artist: name,
count: 50,
})
.then((it) => it.topSongs.song || [])
.then((songs) =>
Promise.all(
songs.map((song) =>
this.getAlbum(song.albumId!).then((album) =>
asTrack(album, song)
)
)
)
)
);
private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> =>
this.subsonic
.getJSON<GetArtistsResponse>(this.credentials, "/rest/getArtists")
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
.then((artists) =>
artists.map((artist) => ({
id: `${artist.id}`,
name: artist.name,
albumCount: artist.albumCount,
image: artistImageURN({
artistId: artist.id,
artistImageURL: artist.artistImageUrl,
}),
}))
);
private getArtistInfo = (
id: string
): Promise<{
similarArtist: (ArtistSummary & { inLibrary: boolean })[];
images: {
s: string | undefined;
m: string | undefined;
l: string | undefined;
};
}> =>
this.subsonic
.getJSON<GetArtistInfoResponse>(this.credentials, "/rest/getArtistInfo2", {
id,
count: 50,
includeNotPresent: true,
})
.then((it) => it.artistInfo2)
.then((it) => ({
images: {
s: it.smallImageUrl,
m: it.mediumImageUrl,
l: it.largeImageUrl,
},
similarArtist: (it.similarArtist || []).map((artist) => ({
id: `${artist.id}`,
name: artist.name,
inLibrary: artistIsInLibrary(artist.id),
image: artistImageURN({
artistId: artist.id,
artistImageURL: artist.artistImageUrl,
}),
})),
}));
private getAlbum = (id: string): Promise<Album> =>
this.subsonic
.getJSON<GetAlbumResponse>(this.credentials, "/rest/getAlbum", { id })
.then((it) => it.album)
.then((album) => ({
id: album.id,
name: album.name,
year: album.year,
genre: maybeAsGenre(album.genre),
artistId: album.artistId,
artistName: album.artist,
coverArt: coverArtURN(album.coverArt),
}));
private getArtist = (
id: string
): Promise<
IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] }
> =>
this.subsonic
.getJSON<GetArtistResponse>(this.credentials, "/rest/getArtist", {
id,
})
.then((it) => it.artist)
.then((it) => ({
id: it.id,
name: it.name,
artistImageUrl: it.artistImageUrl,
albums: this.toAlbumSummary(it.album || []),
}));
private getArtistWithInfo = (id: string) =>
Promise.all([
this.getArtist(id),
this.getArtistInfo(id),
]).then(([artist, artistInfo]) => ({
id: artist.id,
name: artist.name,
image: artistImageURN({
artistId: artist.id,
artistImageURL: [
artist.artistImageUrl,
artistInfo.images.l,
artistInfo.images.m,
artistInfo.images.s,
].find(isValidImage),
}),
albums: artist.albums,
similarArtists: artistInfo.similarArtist,
}));
private getCoverArt = (credentials: Credentials, id: string, size?: number) =>
this.subsonic.get(
credentials,
"/rest/getCoverArt",
size ? { id, size } : { id },
{
headers: { "User-Agent": "bonob" },
responseType: "arraybuffer",
}
);
private getTrack = (id: string) =>
this.subsonic
.getJSON<GetSongResponse>(this.credentials, "/rest/getSong", {
id,
})
.then((it) => it.song)
.then((song) =>
this.getAlbum(song.albumId!).then((album) =>
asTrack(album, song)
)
);
private toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
albumList.map((album) => ({
id: album.id,
name: album.name,
year: album.year,
genre: maybeAsGenre(album.genre),
artistId: album.artistId,
artistName: album.artist,
coverArt: coverArtURN(album.coverArt),
}));
private search3 = (q: any) =>
this.subsonic
.getJSON<Search3Response>(this.credentials, "/rest/search3", {
artistCount: 0,
albumCount: 0,
songCount: 0,
...q,
})
.then((it) => ({
artists: it.searchResult3.artist || [],
albums: it.searchResult3.album || [],
songs: it.searchResult3.song || [],
}));
private getAlbumList2 = (q: AlbumQuery) =>
Promise.all([
this.getArtists().then((it) =>
_.inject(it, (total, artist) => total + artist.albumCount, 0)
),
this.subsonic
.getJSON<GetAlbumListResponse>(this.credentials, "/rest/getAlbumList2", {
type: AlbumQueryTypeToSubsonicType[q.type],
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
size: 500,
offset: q._index,
})
.then((response) => response.albumList2.album || [])
.then(this.toAlbumSummary),
]).then(([total, albums]) => ({
results: albums.slice(0, q._count),
total: albums.length == 500 ? total : (q._index || 0) + albums.length,
}));
}
export class Subsonic implements MusicService {
url: string;
streamClientApplication: StreamClientApplication;
externalImageFetcher: ImageFetcher;
constructor(
url: string,
streamClientApplication: StreamClientApplication = DEFAULT,
externalImageFetcher: ImageFetcher = axiosImageFetcher
) {
this.url = url;
this.streamClientApplication = streamClientApplication;
this.externalImageFetcher = externalImageFetcher;
}
get = async (
{ username, password }: Credentials,
path: string,
q: {} = {},
config: AxiosRequestConfig | undefined = {}
) =>
axios
.get(`${this.url}${path}`, {
params: asURLSearchParams({
u: username,
v: "1.16.1",
c: DEFAULT_CLIENT_APPLICATION,
...t_and_s(password),
...q,
}),
headers: {
"User-Agent": USER_AGENT,
},
...config,
})
.catch((e) => {
throw `Subsonic failed with: ${e}`;
})
.then((response) => {
if (response.status != 200 && response.status != 206) {
throw `Subsonic failed with a ${response.status || "no!"} status`;
} else return response;
});
getJSON = async <T>(
{ username, password }: Credentials,
path: string,
q: {} = {}
): Promise<T> =>
this.get({ username, password }, path, { f: "json", ...q })
.then((response) => response.data as SubsonicEnvelope)
.then((json) => json["subsonic-response"])
.then((json) => {
if (isError(json)) throw `Subsonic error:${json.error.message}`;
else return json as unknown as T;
});
generateToken = (credentials: Credentials) =>
pipe(
TE.tryCatch(
() =>
this.getJSON<PingResponse>(
_.pick(credentials, "username", "password"),
"/rest/ping.view"
),
(e) => new AuthFailure(e as string)
),
TE.chain(({ type }) =>
pipe(
TE.tryCatch(
() => this.libraryFor({ ...credentials, type, bearer: undefined }),
() => new AuthFailure("Failed to get library")
),
TE.map((library) => ({ type, library }))
)
),
TE.chain(({ library, type }) =>
pipe(
library.bearerToken(credentials),
TE.map((bearer) => ({ bearer, type }))
)
),
TE.map(({ bearer, type }) => ({
serviceToken: asToken({ ...credentials, bearer, type }),
userId: credentials.username,
nickname: credentials.username,
}))
);
refreshToken = (serviceToken: string) =>
this.generateToken(parseToken(serviceToken));
login = async (token: string) => this.libraryFor(parseToken(token));
private libraryFor = (
credentials: SubsonicCredentials
): Promise<SubsonicMusicLibrary> => {
const genericSubsonic: SubsonicMusicLibrary =
new SubsonicGenericMusicLibrary(this, credentials);
if (credentials.type == "navidrome") {
return Promise.resolve({
...genericSubsonic,
flavour: () => "navidrome",
bearerToken: (credentials: Credentials) =>
pipe(
TE.tryCatch(
() =>
axios.post(
`${this.url}/auth/login`,
_.pick(credentials, "username", "password")
),
() => new AuthFailure("Failed to get bearerToken")
),
TE.map((it) => it.data.token as string | undefined)
),
artists: async (
q: ArtistQuery
): Promise<Result<ArtistSummary & Sortable>> => {
let params: any = {
_sort: "name",
_order: "ASC",
_start: q._index || "0",
};
if (q._count) {
params = {
...params,
_end: (q._index || 0) + q._count,
};
}
return axios
.get(`${this.url}/api/artist`, {
params: asURLSearchParams(params),
headers: {
"User-Agent": USER_AGENT,
"x-nd-authorization": `Bearer ${credentials.bearer}`,
},
})
.catch((e) => {
throw `Navidrome failed with: ${e}`;
})
.then((response) => {
if (response.status != 200 && response.status != 206) {
throw `Navidrome failed with a ${
response.status || "no!"
} status`;
} else return response;
})
.then((it) => ({
results: (it.data as NDArtist[]).map(artistSummaryFromNDArtist),
total: Number.parseInt(it.headers["x-total-count"] || "0"),
}));
},
});
} else {
return Promise.resolve(genericSubsonic);
}
};
}
export default Subsonic;