mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-22 01:43:29 +01:00
ref
This commit is contained in:
786
src/subsonic.ts
786
src/subsonic.ts
@@ -1,36 +1,25 @@
|
|||||||
import { option as O, taskEither as TE } from "fp-ts";
|
import { 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 { pipe } from "fp-ts/lib/function";
|
||||||
import { Md5 } from "ts-md5/dist/md5";
|
import { Md5 } from "ts-md5/dist/md5";
|
||||||
import {
|
import {
|
||||||
Credentials,
|
Credentials,
|
||||||
MusicService,
|
MusicService,
|
||||||
Album,
|
|
||||||
Result,
|
Result,
|
||||||
slice2,
|
|
||||||
AlbumQuery,
|
|
||||||
ArtistQuery,
|
ArtistQuery,
|
||||||
MusicLibrary,
|
MusicLibrary,
|
||||||
AlbumSummary,
|
|
||||||
Genre,
|
|
||||||
Track,
|
Track,
|
||||||
Rating,
|
|
||||||
AlbumQueryType,
|
|
||||||
Artist,
|
|
||||||
AuthFailure,
|
AuthFailure,
|
||||||
Sortable,
|
Sortable,
|
||||||
|
ArtistSummary,
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
|
|
||||||
import axios, { AxiosRequestConfig } from "axios";
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
import randomstring from "randomstring";
|
import randomstring from "randomstring";
|
||||||
import { b64Encode, b64Decode } from "./b64";
|
import { b64Encode, b64Decode } from "./b64";
|
||||||
import logger from "./logger";
|
|
||||||
import { assertSystem, BUrn } from "./burn";
|
|
||||||
import { artist } from "./smapi";
|
|
||||||
import { axiosImageFetcher, ImageFetcher } from "./images";
|
import { axiosImageFetcher, ImageFetcher } from "./images";
|
||||||
import { asURLSearchParams } from "./utils";
|
import { asURLSearchParams } from "./utils";
|
||||||
|
import { artistImageURN, SubsonicGenericMusicLibrary } from "./subsonic/generic";
|
||||||
|
|
||||||
|
|
||||||
export const t = (password: string, s: string) =>
|
export const t = (password: string, s: string) =>
|
||||||
@@ -44,154 +33,26 @@ export const t_and_s = (password: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// todo: this is an ND thing
|
||||||
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
|
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
|
||||||
|
|
||||||
export const isValidImage = (url: string | undefined) =>
|
|
||||||
url != undefined && !url.endsWith(DODGY_IMAGE_NAME);
|
|
||||||
|
|
||||||
type SubsonicEnvelope = {
|
type SubsonicEnvelope = {
|
||||||
"subsonic-response": SubsonicResponse;
|
"subsonic-response": SubsonicResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubsonicResponse = {
|
export type SubsonicResponse = {
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type album = {
|
export type SubsonicError = SubsonicResponse & {
|
||||||
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: {
|
error: {
|
||||||
code: string;
|
code: string;
|
||||||
message: 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 = {
|
export type PingResponse = {
|
||||||
status: string;
|
status: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -199,14 +60,6 @@ export type PingResponse = {
|
|||||||
serverVersion: string;
|
serverVersion: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Search3Response = SubsonicResponse & {
|
|
||||||
searchResult3: {
|
|
||||||
artist: artist[];
|
|
||||||
album: album[];
|
|
||||||
song: song[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isError(
|
export function isError(
|
||||||
subsonicResponse: SubsonicResponse
|
subsonicResponse: SubsonicResponse
|
||||||
): subsonicResponse is SubsonicError {
|
): subsonicResponse is SubsonicError {
|
||||||
@@ -220,18 +73,38 @@ export type NDArtist = {
|
|||||||
largeImageUrl: string | undefined;
|
largeImageUrl: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IdName = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
export type StreamClientApplication = (track: Track) => string;
|
||||||
|
|
||||||
|
export const DEFAULT_CLIENT_APPLICATION = "bonob";
|
||||||
|
export 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 type SubsonicCredentials = Credentials & {
|
||||||
|
type: string;
|
||||||
|
bearer: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
export const asToken = (credentials: SubsonicCredentials) =>
|
||||||
pipe(
|
b64Encode(JSON.stringify(credentials));
|
||||||
coverArt,
|
export const parseToken = (token: string): SubsonicCredentials =>
|
||||||
O.fromNullable,
|
JSON.parse(b64Decode(token));
|
||||||
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
|
||||||
O.getOrElseW(() => undefined)
|
export interface SubsonicMusicLibrary extends MusicLibrary {
|
||||||
);
|
flavour(): string;
|
||||||
|
bearerToken(
|
||||||
|
credentials: Credentials
|
||||||
|
): TE.TaskEither<Error, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
export const artistSummaryFromNDArtist = (
|
export const artistSummaryFromNDArtist = (
|
||||||
artist: NDArtist
|
artist: NDArtist
|
||||||
@@ -245,591 +118,6 @@ export const artistSummaryFromNDArtist = (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
export class Subsonic implements MusicService {
|
||||||
url: string;
|
url: string;
|
||||||
streamClientApplication: StreamClientApplication;
|
streamClientApplication: StreamClientApplication;
|
||||||
|
|||||||
728
src/subsonic/generic.ts
Normal file
728
src/subsonic/generic.ts
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
import { option as O, taskEither as TE } from "fp-ts";
|
||||||
|
import * as A from "fp-ts/Array";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
import { ordString } from "fp-ts/lib/Ord";
|
||||||
|
import { inject } from 'underscore';
|
||||||
|
|
||||||
|
import logger from "../logger";
|
||||||
|
import { b64Decode, b64Encode } from "../b64";
|
||||||
|
import { assertSystem, BUrn } from "../burn";
|
||||||
|
|
||||||
|
import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, Credentials, Genre, Rating, Result, slice2, Sortable, Track } from "../music_service";
|
||||||
|
import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "../subsonic";
|
||||||
|
|
||||||
|
|
||||||
|
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 GetArtistInfoResponse = SubsonicResponse & {
|
||||||
|
artistInfo2: artistInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetArtistResponse = SubsonicResponse & {
|
||||||
|
artist: artist & {
|
||||||
|
album: album[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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 IdName = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArtistSummary = IdName & {
|
||||||
|
image: BUrn | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
type Search3Response = SubsonicResponse & {
|
||||||
|
searchResult3: {
|
||||||
|
artist: artist[];
|
||||||
|
album: album[];
|
||||||
|
song: song[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||||
|
alphabeticalByArtist: "alphabeticalByArtist",
|
||||||
|
alphabeticalByName: "alphabeticalByName",
|
||||||
|
byGenre: "byGenre",
|
||||||
|
random: "random",
|
||||||
|
recentlyPlayed: "recent",
|
||||||
|
mostPlayed: "frequent",
|
||||||
|
recentlyAdded: "newest",
|
||||||
|
favourited: "starred",
|
||||||
|
starred: "highest",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const isValidImage = (url: string | undefined) =>
|
||||||
|
url != undefined && !url.endsWith(DODGY_IMAGE_NAME);
|
||||||
|
|
||||||
|
const artistIsInLibrary = (artistId: string | undefined) =>
|
||||||
|
artistId != undefined && artistId != "-1";
|
||||||
|
|
||||||
|
|
||||||
|
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
||||||
|
pipe(
|
||||||
|
coverArt,
|
||||||
|
O.fromNullable,
|
||||||
|
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
||||||
|
O.getOrElseW(() => undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// todo: is this the right place for this??
|
||||||
|
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 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
|
|
||||||
import { b64Encode } from "../src/b64";
|
import { b64Encode } from "../src/b64";
|
||||||
import { artistImageURN } from "../src/subsonic";
|
import { artistImageURN } from "../src/subsonic/generic";
|
||||||
|
|
||||||
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
||||||
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
4390
tests/subsonic/generic.test.ts
Normal file
4390
tests/subsonic/generic.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user