tests working

This commit is contained in:
simojenki
2022-04-23 14:18:19 +10:00
parent 2997e5ac3b
commit d2f13416f6
8 changed files with 1886 additions and 1040 deletions

89
src/http.ts Normal file
View File

@@ -0,0 +1,89 @@
import { AxiosPromise, AxiosRequestConfig, ResponseType } from "axios";
import _ from "underscore";
export interface RequestModifier {
(config: AxiosRequestConfig): AxiosRequestConfig;
}
export const no_op = (config: AxiosRequestConfig) => config;
export interface Http {
(config: AxiosRequestConfig): AxiosPromise<any>;
}
// export const http =
// (base: Http = axios, modifier: RequestModifier = no_op): Http =>
// (config: AxiosRequestConfig) => {
// console.log(
// `applying ${JSON.stringify(config)} onto ${JSON.stringify(modifier)}`
// );
// const result = modifier(config);
// console.log(`result is ${JSON.stringify(result)}`);
// return base(result);
// };
// export const chain =
// (...modifiers: RequestModifier[]): RequestModifier =>
// (config: AxiosRequestConfig) =>
// modifiers.reduce(
// (config: AxiosRequestConfig, next: RequestModifier) => next(config),
// config
// );
// export const baseUrl = (baseURL: string) => (config: AxiosRequestConfig) => ({
// ...config,
// baseURL,
// });
// export const axiosConfig =
// (additionalConfig: Partial<AxiosRequestConfig>) =>
// (config: AxiosRequestConfig) => ({ ...config, ...additionalConfig });
// export const params = (params: any) => (config: AxiosRequestConfig) => {
// console.log(
// `params on config ${JSON.stringify(
// config.params
// )}, params applying ${JSON.stringify(params)}`
// );
// const after = { ...config, params: { ...config.params, ...params } };
// console.log(`params after ${JSON.stringify(after.params)}`);
// return after;
// };
// export const headers = (headers: any) => (config: AxiosRequestConfig) => ({
// ...config,
// headers: { ...config.headers, ...headers },
// });
// export const formatJson = (): RequestModifier => (config: AxiosRequestConfig) => ({...config, params: { ...config.params, f: 'json' } });
// export const subsonicAuth = (credentials: { username: string, password: string}) => (config: AxiosRequestConfig) => ({...config, params: { ...config.params, u: credentials.username, ...t_and_s(credentials.password) } });
export type RequestParams = { baseURL: string; url: string, params: any, headers: any, responseType: ResponseType }
// todo: rename to http
export const http2 =
(base: Http, defaults: Partial<RequestParams>): Http =>
(config: AxiosRequestConfig) => {
let toApply = {
...defaults,
...config,
};
if (defaults.params) {
toApply = {
...toApply,
params: {
...defaults.params,
...config.params,
},
};
}
if (defaults.headers) {
toApply = {
...toApply,
headers: {
...defaults.headers,
...config.headers,
},
};
}
return base(toApply);
};

113
src/subsonic/http.ts Normal file
View File

@@ -0,0 +1,113 @@
import axios, { AxiosPromise, AxiosRequestConfig } from "axios";
import {
DEFAULT_CLIENT_APPLICATION,
isError,
SubsonicEnvelope,
t_and_s,
USER_AGENT,
} from ".";
import { Http, http2 } from "../http";
import { Credentials } from "../music_service";
import { asURLSearchParams } from "../utils";
export const http = (base: string, credentials: Credentials): HTTP => ({
get: async (
path: string,
params: Partial<{ q: {}; config: AxiosRequestConfig | undefined }>
) =>
axios
.get(`${base}${path}`, {
params: asURLSearchParams({
u: credentials.username,
v: "1.16.1",
c: DEFAULT_CLIENT_APPLICATION,
...t_and_s(credentials.password),
f: "json",
...(params.q || {}),
}),
headers: {
"User-Agent": USER_AGENT,
},
...(params.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;
}),
});
export type HttpResponse = {
data: any;
status: number;
headers: any;
};
export interface HTTP {
get(
path: string,
params: Partial<{ q: {}; config: AxiosRequestConfig | undefined }>
): Promise<HttpResponse>;
}
// export const basic = (opts : AxiosRequestConfig) => axios(opts);
// function whatDoesItLookLike() {
// const basic = axios;
// const authenticatedClient = httpee(axios, chain(
// baseUrl("http://foobar"),
// subsonicAuth({username: 'bob', password: 'foo'})
// ));
// const jsonClient = httpee(authenticatedClient, formatJson())
// jsonClient({ })
// }
// .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;
// });
export const raw = (response: AxiosPromise<any>) =>
response
.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;
});
// todo: delete
export const getRaw2 = (http: Http) =>
http({ method: "get" })
.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;
});
export const getJSON = async <T>(http: Http): Promise<T> =>
getRaw2(http2(http, { params: { f: "json" } })).then(asJSON) as Promise<T>;
export const asJSON = <T>(response: HttpResponse): T => {
const subsonicResponse = (response.data as SubsonicEnvelope)[
"subsonic-response"
];
if (isError(subsonicResponse))
throw `Subsonic error:${subsonicResponse.error.message}`;
else return subsonicResponse as unknown as T;
};
export default http;

View File

@@ -1,9 +1,10 @@
import { taskEither as TE } from "fp-ts"; import { taskEither as TE } from "fp-ts";
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 axios, { AxiosRequestConfig } from "axios"; import axios from "axios";
import randomstring from "randomstring"; import randomstring from "randomstring";
import _ from "underscore"; import _ from "underscore";
import { Http, http2 } from "../http";
import { import {
Credentials, Credentials,
@@ -14,8 +15,8 @@ import {
} from "../music_service"; } from "../music_service";
import { b64Encode, b64Decode } from "../b64"; import { b64Encode, b64Decode } from "../b64";
import { axiosImageFetcher, ImageFetcher } from "../images"; import { axiosImageFetcher, ImageFetcher } from "../images";
import { asURLSearchParams } from "../utils";
import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library";
import { http, getJSON as getJSON2 } from "./http";
export const t = (password: string, s: string) => export const t = (password: string, s: string) =>
Md5.hashStr(`${password}${s}`); Md5.hashStr(`${password}${s}`);
@@ -31,9 +32,7 @@ export const t_and_s = (password: string) => {
// todo: this is an ND thing // todo: this is an ND thing
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png"; export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
export type SubsonicEnvelope = {
type SubsonicEnvelope = {
"subsonic-response": SubsonicResponse; "subsonic-response": SubsonicResponse;
}; };
@@ -61,9 +60,6 @@ export function isError(
return (subsonicResponse as SubsonicError).error !== undefined; return (subsonicResponse as SubsonicError).error !== undefined;
} }
export type StreamClientApplication = (track: Track) => string; export type StreamClientApplication = (track: Track) => string;
export const DEFAULT_CLIENT_APPLICATION = "bonob"; export const DEFAULT_CLIENT_APPLICATION = "bonob";
@@ -77,7 +73,6 @@ export function appendMimeTypeToClientFor(mimeTypes: string[]) {
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob"; mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
} }
export type SubsonicCredentials = Credentials & { export type SubsonicCredentials = Credentials & {
type: string; type: string;
bearer: string | undefined; bearer: string | undefined;
@@ -85,7 +80,7 @@ export type SubsonicCredentials = Credentials & {
export const asToken = (credentials: SubsonicCredentials) => export const asToken = (credentials: SubsonicCredentials) =>
b64Encode(JSON.stringify(credentials)); b64Encode(JSON.stringify(credentials));
export const parseToken = (token: string): SubsonicCredentials => export const parseToken = (token: string): SubsonicCredentials =>
JSON.parse(b64Decode(token)); JSON.parse(b64Decode(token));
@@ -101,6 +96,7 @@ export class Subsonic implements MusicService {
streamClientApplication: StreamClientApplication; streamClientApplication: StreamClientApplication;
// todo: why is this in here? // todo: why is this in here?
externalImageFetcher: ImageFetcher; externalImageFetcher: ImageFetcher;
base: Http;
constructor( constructor(
url: string, url: string,
@@ -110,58 +106,34 @@ export class Subsonic implements MusicService {
this.url = url; this.url = url;
this.streamClientApplication = streamClientApplication; this.streamClientApplication = streamClientApplication;
this.externalImageFetcher = externalImageFetcher; this.externalImageFetcher = externalImageFetcher;
this.base = http2(axios, {
baseURL: this.url,
params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION },
headers: { "User-Agent": "bonob" },
});
} }
get = async ( // todo: delete
{ username, password }: Credentials, http = (credentials: Credentials) => http(this.url, credentials);
path: string,
q: {} = {}, authenticated = (credentials: Credentials, wrap: Http = this.base) =>
config: AxiosRequestConfig | undefined = {} http2(wrap, {
) => params: {
axios u: credentials.username,
.get(`${this.url}${path}`, { ...t_and_s(credentials.password),
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>( getJSON = async <T>(
{ username, password }: Credentials, credentials: Credentials,
path: string, url: string,
q: {} = {} params: {} = {}
): Promise<T> => ): Promise<T> => getJSON2(http2(this.authenticated(credentials), { url, params }));
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) => generateToken = (credentials: Credentials) =>
pipe( pipe(
TE.tryCatch( TE.tryCatch(
() => () => getJSON2<PingResponse>(http2(this.authenticated(credentials), { url: "/rest/ping.view" })),
this.getJSON<PingResponse>(
_.pick(credentials, "username", "password"),
"/rest/ping.view"
),
(e) => new AuthFailure(e as string) (e) => new AuthFailure(e as string)
), ),
TE.chain(({ type }) => TE.chain(({ type }) =>
@@ -194,9 +166,15 @@ export class Subsonic implements MusicService {
private libraryFor = ( private libraryFor = (
credentials: SubsonicCredentials credentials: SubsonicCredentials
): Promise<SubsonicMusicLibrary> => { ): Promise<SubsonicMusicLibrary> => {
const subsonicGenericLibrary = new SubsonicGenericMusicLibrary(this, credentials); const subsonicGenericLibrary = new SubsonicGenericMusicLibrary(
this,
credentials,
this.authenticated(credentials, this.base)
);
if (credentials.type == "navidrome") { if (credentials.type == "navidrome") {
return Promise.resolve(navidromeMusicLibrary(this.url, subsonicGenericLibrary, credentials)); return Promise.resolve(
navidromeMusicLibrary(this.url, subsonicGenericLibrary, credentials)
);
} else { } else {
return Promise.resolve(subsonicGenericLibrary); return Promise.resolve(subsonicGenericLibrary);
} }

View File

@@ -2,19 +2,43 @@ import { option as O, taskEither as TE } from "fp-ts";
import * as A from "fp-ts/Array"; import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/lib/function"; import { pipe } from "fp-ts/lib/function";
import { ordString } from "fp-ts/lib/Ord"; import { ordString } from "fp-ts/lib/Ord";
import { inject } from 'underscore'; import { inject } from "underscore";
import _ from "underscore"; import _ from "underscore";
import logger from "../logger"; import logger from "../logger";
import { b64Decode, b64Encode } from "../b64"; import { b64Decode, b64Encode } from "../b64";
import { assertSystem, BUrn } from "../burn"; import { assertSystem, BUrn, format } from "../burn";
import { Album, AlbumQuery, AlbumQueryType, AlbumSummary, Artist, ArtistQuery, ArtistSummary, AuthFailure, Credentials, Genre, IdName, Rating, Result, slice2, Sortable, Track } from "../music_service"; import {
import Subsonic, { DODGY_IMAGE_NAME, SubsonicCredentials, SubsonicMusicLibrary, SubsonicResponse, USER_AGENT } from "."; Album,
AlbumQuery,
AlbumQueryType,
AlbumSummary,
Artist,
ArtistQuery,
ArtistSummary,
AuthFailure,
Credentials,
Genre,
IdName,
Rating,
Result,
slice2,
Sortable,
Track,
} from "../music_service";
import Subsonic, {
DODGY_IMAGE_NAME,
SubsonicCredentials,
SubsonicMusicLibrary,
SubsonicResponse,
USER_AGENT,
} from ".";
import axios from "axios"; import axios from "axios";
import { asURLSearchParams } from "../utils"; import { asURLSearchParams } from "../utils";
import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome";
import { Http, http2 } from "../http";
import { getRaw2 } from "./http";
type album = { type album = {
id: string; id: string;
@@ -60,7 +84,6 @@ type GetGenresResponse = SubsonicResponse & {
}; };
}; };
type GetArtistInfoResponse = SubsonicResponse & { type GetArtistInfoResponse = SubsonicResponse & {
artistInfo2: artistInfo; artistInfo2: artistInfo;
}; };
@@ -71,7 +94,6 @@ type GetArtistResponse = SubsonicResponse & {
}; };
}; };
export type images = { export type images = {
smallImageUrl: string | undefined; smallImageUrl: string | undefined;
mediumImageUrl: string | undefined; mediumImageUrl: string | undefined;
@@ -85,7 +107,6 @@ type artistInfo = images & {
similarArtist: artist[]; similarArtist: artist[];
}; };
export type song = { export type song = {
id: string; id: string;
parent: string | undefined; parent: string | undefined;
@@ -143,7 +164,6 @@ type GetSongResponse = {
song: song; song: song;
}; };
type Search3Response = SubsonicResponse & { type Search3Response = SubsonicResponse & {
searchResult3: { searchResult3: {
artist: artist[]; artist: artist[];
@@ -164,14 +184,12 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
starred: "highest", starred: "highest",
}; };
export const isValidImage = (url: string | undefined) => export const isValidImage = (url: string | undefined) =>
url != undefined && !url.endsWith(DODGY_IMAGE_NAME); url != undefined && !url.endsWith(DODGY_IMAGE_NAME);
const artistIsInLibrary = (artistId: string | undefined) => const artistIsInLibrary = (artistId: string | undefined) =>
artistId != undefined && artistId != "-1"; artistId != undefined && artistId != "-1";
const coverArtURN = (coverArt: string | undefined): BUrn | undefined => const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
pipe( pipe(
coverArt, coverArt,
@@ -180,8 +198,7 @@ const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
O.getOrElseW(() => undefined) O.getOrElseW(() => undefined)
); );
// todo: is this the right place for this??
// todo: is this the right place for this??
export const artistImageURN = ( export const artistImageURN = (
spec: Partial<{ spec: Partial<{
artistId: string | undefined; artistId: string | undefined;
@@ -256,23 +273,27 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
O.getOrElseW(() => undefined) O.getOrElseW(() => undefined)
); );
export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
subsonic: Subsonic; subsonic: Subsonic;
credentials: SubsonicCredentials; credentials: SubsonicCredentials;
http: Http;
constructor(subsonic: Subsonic, credentials: SubsonicCredentials) { constructor(
subsonic: Subsonic,
credentials: SubsonicCredentials,
http: Http
) {
this.subsonic = subsonic; this.subsonic = subsonic;
this.credentials = credentials; this.credentials = credentials;
this.http = http;
} }
flavour = () => "subsonic"; flavour = () => "subsonic";
bearerToken = (_: Credentials): TE.TaskEither<Error, string | undefined> => TE.right(undefined); bearerToken = (_: Credentials): TE.TaskEither<Error, string | undefined> =>
TE.right(undefined);
artists = async ( artists = async (q: ArtistQuery): Promise<Result<ArtistSummary & Sortable>> =>
q: ArtistQuery
): Promise<Result<ArtistSummary & Sortable>> =>
this.getArtists() this.getArtists()
.then(slice2(q)) .then(slice2(q))
.then(([page, total]) => ({ .then(([page, total]) => ({
@@ -285,8 +306,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
})), })),
})); }));
artist = async (id: string): Promise<Artist> => artist = async (id: string): Promise<Artist> => this.getArtistWithInfo(id);
this.getArtistWithInfo(id);
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> => albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
this.getAlbumList2(q); this.getAlbumList2(q);
@@ -360,29 +380,28 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
trackId: string; trackId: string;
range: string | undefined; range: string | undefined;
}) => }) =>
// todo: all these headers and stuff can be rolled into httpeee
this.getTrack(trackId).then((track) => this.getTrack(trackId).then((track) =>
this.subsonic getRaw2(
.get( http2(this.http, {
this.credentials, url: `/rest/stream`,
`/rest/stream`, params: {
{ id: trackId,
id: trackId, c: this.subsonic.streamClientApplication(track),
c: this.subsonic.streamClientApplication(track), },
},
{
headers: pipe( headers: pipe(
range, range,
O.fromNullable, O.fromNullable,
O.map((range) => ({ O.map((range) => ({
"User-Agent": USER_AGENT, // "User-Agent": USER_AGENT,
Range: range, Range: range,
})), })),
O.getOrElse(() => ({ O.getOrElse(() => ({
"User-Agent": USER_AGENT, // "User-Agent": USER_AGENT,
})) }))
), ),
responseType: "stream", responseType: "stream",
} })
) )
.then((res) => ({ .then((res) => ({
status: res.status, status: res.status,
@@ -406,7 +425,9 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
data: Buffer.from(res.data, "binary"), data: Buffer.from(res.data, "binary"),
})) }))
.catch((e) => { .catch((e) => {
logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); logger.error(
`Failed getting coverArt for '${format(coverArtURN)}': ${e}`
);
return undefined; return undefined;
}); });
@@ -429,21 +450,20 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
.catch(() => false); .catch(() => false);
searchArtists = async (query: string) => searchArtists = async (query: string) =>
this.search3({ query, artistCount: 20 }).then( this.search3({ query, artistCount: 20 }).then(({ artists }) =>
({ artists }) => artists.map((artist) => ({
artists.map((artist) => ({ id: artist.id,
id: artist.id, name: artist.name,
name: artist.name, image: artistImageURN({
image: artistImageURN({ artistId: artist.id,
artistId: artist.id, artistImageURL: artist.artistImageUrl,
artistImageURL: artist.artistImageUrl, }),
}), }))
}))
); );
searchAlbums = async (query: string) => searchAlbums = async (query: string) =>
this.search3({ query, albumCount: 20 }).then( this.search3({ query, albumCount: 20 }).then(({ albums }) =>
({ albums }) => this.toAlbumSummary(albums) this.toAlbumSummary(albums)
); );
searchTracks = async (query: string) => searchTracks = async (query: string) =>
@@ -530,9 +550,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
.then((songs) => .then((songs) =>
Promise.all( Promise.all(
songs.map((song) => songs.map((song) =>
this.getAlbum(song.albumId!).then((album) => this.getAlbum(song.albumId!).then((album) => asTrack(album, song))
asTrack(album, song)
)
) )
) )
); );
@@ -548,15 +566,15 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
.then((songs) => .then((songs) =>
Promise.all( Promise.all(
songs.map((song) => songs.map((song) =>
this.getAlbum(song.albumId!).then((album) => this.getAlbum(song.albumId!).then((album) => asTrack(album, song))
asTrack(album, song)
)
) )
) )
) )
); );
private getArtists = (): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => private getArtists = (): Promise<
(IdName & { albumCount: number; image: BUrn | undefined })[]
> =>
this.subsonic this.subsonic
.getJSON<GetArtistsResponse>(this.credentials, "/rest/getArtists") .getJSON<GetArtistsResponse>(this.credentials, "/rest/getArtists")
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) .then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
@@ -583,11 +601,15 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
}; };
}> => }> =>
this.subsonic this.subsonic
.getJSON<GetArtistInfoResponse>(this.credentials, "/rest/getArtistInfo2", { .getJSON<GetArtistInfoResponse>(
id, this.credentials,
count: 50, "/rest/getArtistInfo2",
includeNotPresent: true, {
}) id,
count: 50,
includeNotPresent: true,
}
)
.then((it) => it.artistInfo2) .then((it) => it.artistInfo2)
.then((it) => ({ .then((it) => ({
images: { images: {
@@ -638,35 +660,30 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
})); }));
private getArtistWithInfo = (id: string) => private getArtistWithInfo = (id: string) =>
Promise.all([ Promise.all([this.getArtist(id), this.getArtistInfo(id)]).then(
this.getArtist(id), ([artist, artistInfo]) => ({
this.getArtistInfo(id), id: artist.id,
]).then(([artist, artistInfo]) => ({ name: artist.name,
id: artist.id, image: artistImageURN({
name: artist.name, artistId: artist.id,
image: artistImageURN({ artistImageURL: [
artistId: artist.id, artist.artistImageUrl,
artistImageURL: [ artistInfo.images.l,
artist.artistImageUrl, artistInfo.images.m,
artistInfo.images.l, artistInfo.images.s,
artistInfo.images.m, ].find(isValidImage),
artistInfo.images.s, }),
].find(isValidImage), albums: artist.albums,
}), similarArtists: artistInfo.similarArtist,
albums: artist.albums, })
similarArtists: artistInfo.similarArtist, );
}));
private getCoverArt = (credentials: Credentials, id: string, size?: number) => private getCoverArt = (credentials: Credentials, id: string, size?: number) =>
this.subsonic.get( getRaw2(http2(this.subsonic.authenticated(credentials), {
credentials, url: "/rest/getCoverArt",
"/rest/getCoverArt", params: { id, size },
size ? { id, size } : { id }, responseType: "arraybuffer",
{ }));
headers: { "User-Agent": "bonob" },
responseType: "arraybuffer",
}
);
private getTrack = (id: string) => private getTrack = (id: string) =>
this.subsonic this.subsonic
@@ -675,9 +692,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
}) })
.then((it) => it.song) .then((it) => it.song)
.then((song) => .then((song) =>
this.getAlbum(song.albumId!).then((album) => this.getAlbum(song.albumId!).then((album) => asTrack(album, song))
asTrack(album, song)
)
); );
private toAlbumSummary = (albumList: album[]): AlbumSummary[] => private toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
@@ -711,73 +726,83 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
inject(it, (total, artist) => total + artist.albumCount, 0) inject(it, (total, artist) => total + artist.albumCount, 0)
), ),
this.subsonic this.subsonic
.getJSON<GetAlbumListResponse>(this.credentials, "/rest/getAlbumList2", { .getJSON<GetAlbumListResponse>(
type: AlbumQueryTypeToSubsonicType[q.type], this.credentials,
...(q.genre ? { genre: b64Decode(q.genre) } : {}), "/rest/getAlbumList2",
size: 500, {
offset: q._index, type: AlbumQueryTypeToSubsonicType[q.type],
}) ...(q.genre ? { genre: b64Decode(q.genre) } : {}),
size: 500,
offset: q._index,
}
)
.then((response) => response.albumList2.album || []) .then((response) => response.albumList2.album || [])
.then(this.toAlbumSummary), .then(this.toAlbumSummary),
]).then(([total, albums]) => ({ ]).then(([total, albums]) => ({
results: albums.slice(0, q._count), results: albums.slice(0, q._count),
total: albums.length == 500 ? total : (q._index || 0) + albums.length, total: albums.length == 500 ? total : (q._index || 0) + albums.length,
})); }));
}; }
export const navidromeMusicLibrary = (url: string, subsonicLibrary: SubsonicMusicLibrary, subsonicCredentials: SubsonicCredentials): SubsonicMusicLibrary => ({ export const navidromeMusicLibrary = (
url: string,
subsonicLibrary: SubsonicMusicLibrary,
subsonicCredentials: SubsonicCredentials
): SubsonicMusicLibrary => ({
...subsonicLibrary, ...subsonicLibrary,
flavour: () => "navidrome", flavour: () => "navidrome",
bearerToken: (credentials: Credentials): TE.TaskEither<Error, string | undefined> => bearerToken: (
credentials: Credentials
): TE.TaskEither<Error, string | undefined> =>
pipe( pipe(
TE.tryCatch( TE.tryCatch(
() => () =>
axios.post( axios({
`${url}/auth/login`, method: 'post',
_.pick(credentials, "username", "password") baseURL: url,
), url: `/auth/login`,
data: _.pick(credentials, "username", "password")
}),
() => new AuthFailure("Failed to get bearerToken") () => new AuthFailure("Failed to get bearerToken")
), ),
TE.map((it) => it.data.token as string | undefined) TE.map((it) => it.data.token as string | undefined)
), ),
artists: async ( artists: async (
q: ArtistQuery q: ArtistQuery
): Promise<Result<ArtistSummary & Sortable>> => { ): Promise<Result<ArtistSummary & Sortable>> => {
let params: any = { let params: any = {
_sort: "name", _sort: "name",
_order: "ASC", _order: "ASC",
_start: q._index || "0", _start: q._index || "0",
};
if (q._count) {
params = {
...params,
_end: (q._index || 0) + q._count,
}; };
if (q._count) { }
params = {
...params, const x: Promise<Result<ArtistSummary & Sortable>> = axios
_end: (q._index || 0) + q._count, .get(`${url}/api/artist`, {
}; params: asURLSearchParams(params),
} headers: {
"User-Agent": USER_AGENT,
const x: Promise<Result<ArtistSummary & Sortable>> = axios "x-nd-authorization": `Bearer ${subsonicCredentials.bearer}`,
.get(`${url}/api/artist`, { },
params: asURLSearchParams(params), })
headers: { .catch((e) => {
"User-Agent": USER_AGENT, throw `Navidrome failed with: ${e}`;
"x-nd-authorization": `Bearer ${subsonicCredentials.bearer}`, })
}, .then((response) => {
}) if (response.status != 200 && response.status != 206) {
.catch((e) => { throw `Navidrome failed with a ${response.status || "no!"} status`;
throw `Navidrome failed with: ${e}`; } else return response;
}) })
.then((response) => { .then((it) => ({
if (response.status != 200 && response.status != 206) { results: (it.data as NDArtist[]).map(artistSummaryFromNDArtist),
throw `Navidrome failed with a ${ total: Number.parseInt(it.headers["x-total-count"] || "0"),
response.status || "no!" }));
} status`;
} else return response; return x;
}) },
.then((it) => ({ });
results: (it.data as NDArtist[]).map(artistSummaryFromNDArtist),
total: Number.parseInt(it.headers["x-total-count"] || "0"),
}));
return x;
}
})

View File

@@ -1,5 +1,6 @@
import { flatten } from "underscore"; import { flatten } from "underscore";
// todo: move this
export const BROWSER_HEADERS = { export const BROWSER_HEADERS = {
accept: accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
@@ -10,7 +11,7 @@ export const BROWSER_HEADERS = {
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
}; };
// todo: move this
export const asURLSearchParams = (q: any) => { export const asURLSearchParams = (q: any) => {
const urlSearchParams = new URLSearchParams(); const urlSearchParams = new URLSearchParams();
Object.keys(q).forEach((k) => { Object.keys(q).forEach((k) => {

286
tests/http.test.ts Normal file
View File

@@ -0,0 +1,286 @@
import {
http2,
} from "../src/http";
// describe("request modifiers", () => {
// describe("baseUrl", () => {
// it.each([
// [
// { data: "bob" },
// "http://example.com",
// { data: "bob", baseURL: "http://example.com" },
// ],
// [
// { baseURL: "http://originalBaseUrl.example.com" },
// "http://example.com",
// { baseURL: "http://example.com" },
// ],
// ])(
// "should apply the baseUrl",
// (requestConfig: any, value: string, expected: any) => {
// expect(baseUrl(value)(requestConfig)).toEqual(expected);
// }
// );
// });
// describe("params", () => {
// it.each([
// [
// { data: "bob" },
// { param1: "value1", param2: "value2" },
// { data: "bob", params: { param1: "value1", param2: "value2" } },
// ],
// [
// { data: "bob", params: { orig1: "origValue1" } },
// {},
// { data: "bob", params: { orig1: "origValue1" } },
// ],
// [
// { data: "bob", params: { orig1: "origValue1" } },
// { param1: "value1", param2: "value2" },
// {
// data: "bob",
// params: { orig1: "origValue1", param1: "value1", param2: "value2" },
// },
// ],
// ])(
// "should apply the params",
// (requestConfig: any, newParams: any, expected: any) => {
// expect(params(newParams)(requestConfig)).toEqual(expected);
// }
// );
// });
// describe("headers", () => {
// it.each([
// [
// { data: "bob" },
// { h1: "value1", h2: "value2" },
// { data: "bob", headers: { h1: "value1", h2: "value2" } },
// ],
// [
// { data: "bob", headers: { orig1: "origValue1" } },
// {},
// { data: "bob", headers: { orig1: "origValue1" } },
// ],
// [
// { data: "bob", headers: { orig1: "origValue1" } },
// { h1: "value1", h2: "value2" },
// {
// data: "bob",
// headers: { orig1: "origValue1", h1: "value1", h2: "value2" },
// },
// ],
// ])(
// "should apply the headers",
// (requestConfig: any, newParams: any, expected: any) => {
// expect(headers(newParams)(requestConfig)).toEqual(expected);
// }
// );
// });
// describe("chain", () => {
// it.each([
// [
// { data: "bob" },
// [params({ param1: "value1", param2: "value2" })],
// { data: "bob", params: { param1: "value1", param2: "value2" } },
// ],
// [
// { data: "bob" },
// [params({ param1: "value1" }), params({ param2: "value2" })],
// { data: "bob", params: { param1: "value1", param2: "value2" } },
// ],
// [{ data: "bob" }, [], { data: "bob" }],
// ])(
// "should apply the chain",
// (requestConfig: any, newParams: RequestModifier[], expected: any) => {
// expect(chain(...newParams)(requestConfig)).toEqual(expected);
// }
// );
// });
// describe("wrapping", () => {
// const mockAxios = jest.fn();
// describe("baseURL", () => {
// const base = http(
// mockAxios,
// baseUrl("http://original.example.com")
// );
// describe("when no baseURL passed in when being invoked", () => {
// it("should use the original value", () => {
// base({})
// expect(mockAxios).toHaveBeenCalledWith({ baseURL: "http://original.example.com" });
// });
// });
// describe("when a new baseURL is passed in when being invoked", () => {
// it("should use the new value", () => {
// base({ baseURL: "http://new.example.com" })
// expect(mockAxios).toHaveBeenCalledWith({ baseURL: "http://new.example.com" });
// });
// });
// });
// describe("params", () => {
// const base = http(
// mockAxios,
// params({ a: "1", b: "2" })
// );
// it("should apply the modified when invoked", () => {
// base({ method: 'get' });
// expect(mockAxios).toHaveBeenCalledWith({ method: 'get', params: { a: "1", b: "2" }});
// });
// describe("wrapping the base", () => {
// const wrapped = http(base, params({ b: "2b", c: "3" }));
// it("should the wrapped values as priority", () => {
// wrapped({ method: 'get', params: { a: "1b", c: "3b", d: "4" } });
// expect(mockAxios).toHaveBeenCalledWith({ method: 'get', params: { a: "1b", b: "2b", c: "3b", d: "4" }});
// });
// });
// });
// });
// });
describe("http2", () => {
const mockAxios = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe.each([
["baseURL"],
["url"],
])('%s', (field) => {
const getValue = (value: string) => {
const thing = {} as any;
thing[field] = value;
return thing;
};
const base = http2(mockAxios, getValue('base'));
describe("using default", () => {
it("should use the default", () => {
base({})
expect(mockAxios).toHaveBeenCalledWith(getValue('base'));
});
});
describe("overriding", () => {
it("should use the override", () => {
base(getValue('override'))
expect(mockAxios).toHaveBeenCalledWith(getValue('override'));
});
});
describe("wrapping", () => {
const firstLayer = http2(base, getValue('level1'));
const secondLayer = http2(firstLayer, getValue('level2'));
describe("when the outter call provides a value", () => {
it("should apply it", () => {
secondLayer(getValue('outter'))
expect(mockAxios).toHaveBeenCalledWith(getValue('outter'));
});
});
describe("when the outter call does not provide a value", () => {
it("should use the second layer", () => {
secondLayer({ })
expect(mockAxios).toHaveBeenCalledWith(getValue('level2'));
});
});
});
});
describe("requestType", () => {
const base = http2(mockAxios, { responseType: 'stream' });
describe("using default", () => {
it("should use the default", () => {
base({})
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'stream' });
});
});
describe("overriding", () => {
it("should use the override", () => {
base({ responseType: 'arraybuffer' })
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'arraybuffer' });
});
});
describe("wrapping", () => {
const firstLayer = http2(base, { responseType: 'arraybuffer' });
const secondLayer = http2(firstLayer, { responseType: 'blob' });
describe("when the outter call provides a value", () => {
it("should apply it", () => {
secondLayer({ responseType: 'text' })
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'text' });
});
});
describe("when the outter call does not provide a value", () => {
it("should use the second layer", () => {
secondLayer({ })
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'blob' });
});
});
});
});
describe.each([
["params"],
["headers"],
])('%s', (field) => {
const getValues = (values: any) => {
const thing = {} as any;
thing[field] = values;
return thing;
}
const base = http2(mockAxios, getValues({ a: 1, b: 2, c: 3, d: 4 }));
describe("using default", () => {
it("should use the default", () => {
base({});
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 2, c: 3, d: 4 }));
});
});
describe("overriding", () => {
it("should use the override", () => {
base(getValues({ b: 22, e: 5 }));
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 22, c: 3, d: 4, e: 5 }));
});
});
describe("wrapping", () => {
const firstLayer = http2(base, getValues({ b: 22 }));
const secondLayer = http2(firstLayer, getValues({ c: 33 }));
describe("when the outter call provides a value", () => {
it("should apply it", () => {
secondLayer(getValues({ a: 11, e: 5 }));
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 11, b: 22, c: 33, d: 4, e: 5 }));
});
});
describe("when the outter call does not provide a value", () => {
it("should use the second layer", () => {
secondLayer({ });
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 22, c: 33, d: 4 }));
});
});
});
})
});

View File

@@ -17,7 +17,6 @@ import {
import axios from "axios"; import axios from "axios";
jest.mock("axios"); jest.mock("axios");
import randomstring from "randomstring"; import randomstring from "randomstring";
jest.mock("randomstring"); jest.mock("randomstring");
@@ -27,7 +26,6 @@ import {
import { import {
aTrack, aTrack,
} from "./builders"; } from "./builders";
import { asURLSearchParams } from "../src/utils";
describe("t", () => { describe("t", () => {
it("should be an md5 of the password and the salt", () => { it("should be an md5 of the password and the salt", () => {
@@ -129,7 +127,10 @@ const pingJson = (pingResponse: Partial<PingResponse> = {}) => ({
const PING_OK = pingJson({ status: "ok" }); const PING_OK = pingJson({ status: "ok" });
describe("Subsonic", () => { describe("Subsonic", () => {
const mockAxios = axios as unknown as jest.Mock;
const url = "http://127.0.0.22:4567"; const url = "http://127.0.0.22:4567";
const baseURL = url;
const username = `user1-${uuid()}`; const username = `user1-${uuid()}`;
const password = `pass1-${uuid()}`; const password = `pass1-${uuid()}`;
const salt = "saltysalty"; const salt = "saltysalty";
@@ -141,16 +142,12 @@ describe("Subsonic", () => {
); );
const mockRandomstring = jest.fn(); const mockRandomstring = jest.fn();
const mockGET = jest.fn();
const mockPOST = jest.fn();
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.resetAllMocks(); jest.resetAllMocks();
randomstring.generate = mockRandomstring; randomstring.generate = mockRandomstring;
axios.get = mockGET;
axios.post = mockPOST;
mockRandomstring.mockReturnValue(salt); mockRandomstring.mockReturnValue(salt);
}); });
@@ -187,7 +184,7 @@ describe("Subsonic", () => {
describe("when the credentials are valid", () => { describe("when the credentials are valid", () => {
describe("when the backend is generic subsonic", () => { describe("when the backend is generic subsonic", () => {
it("should be able to generate a token and then login using it", async () => { it("should be able to generate a token and then login using it", async () => {
(axios.get as jest.Mock).mockResolvedValue(ok(PING_OK)); (mockAxios as jest.Mock).mockResolvedValue(ok(PING_OK));
const token = await tokenFor({ const token = await tokenFor({
username, username,
@@ -200,15 +197,18 @@ describe("Subsonic", () => {
expect(parseToken(token.serviceToken)).toEqual({ username, password, type: PING_OK["subsonic-response"].type }) expect(parseToken(token.serviceToken)).toEqual({ username, password, type: PING_OK["subsonic-response"].type })
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { expect(mockAxios).toHaveBeenCalledWith({
params: asURLSearchParams(authParamsPlusJson), method: 'get',
baseURL,
url: `/rest/ping.view`,
params: authParamsPlusJson,
headers, headers,
}); });
}); });
it("should store the type of the subsonic server on the token", async () => { it("should store the type of the subsonic server on the token", async () => {
const type = "someSubsonicClone"; const type = "someSubsonicClone";
(axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); mockAxios.mockResolvedValue(ok(pingJson({ type })));
const token = await tokenFor({ const token = await tokenFor({
username, username,
@@ -221,8 +221,11 @@ describe("Subsonic", () => {
expect(parseToken(token.serviceToken)).toEqual({ username, password, type }) expect(parseToken(token.serviceToken)).toEqual({ username, password, type })
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { expect(mockAxios).toHaveBeenCalledWith({
params: asURLSearchParams(authParamsPlusJson), method: 'get',
baseURL,
url: `/rest/ping.view`,
params: authParamsPlusJson,
headers, headers,
}); });
}); });
@@ -232,8 +235,9 @@ describe("Subsonic", () => {
it("should login to nd and get the nd bearer token", async () => { it("should login to nd and get the nd bearer token", async () => {
const navidromeToken = `nd-${uuid()}`; const navidromeToken = `nd-${uuid()}`;
(axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); mockAxios
(axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); .mockResolvedValueOnce(ok(pingJson({ type: "navidrome" })))
.mockResolvedValueOnce(ok({ token: navidromeToken }));
const token = await tokenFor({ const token = await tokenFor({
username, username,
@@ -246,13 +250,21 @@ describe("Subsonic", () => {
expect(parseToken(token.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) expect(parseToken(token.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken })
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { expect(mockAxios).toHaveBeenCalledWith({
params: asURLSearchParams(authParamsPlusJson), method: 'get',
baseURL,
url: `/rest/ping.view`,
params: authParamsPlusJson,
headers, headers,
}); });
expect(axios.post).toHaveBeenCalledWith(`${url}/auth/login`, { expect(mockAxios).toHaveBeenCalledWith({
username, method: 'post',
password, baseURL,
url: `/auth/login`,
data: {
username,
password,
}
}); });
}); });
}); });
@@ -260,7 +272,7 @@ describe("Subsonic", () => {
describe("when the credentials are not valid", () => { describe("when the credentials are not valid", () => {
it("should be able to generate a token and then login using it", async () => { it("should be able to generate a token and then login using it", async () => {
(axios.get as jest.Mock).mockResolvedValue({ mockAxios.mockResolvedValue({
status: 200, status: 200,
data: error("40", "Wrong username or password"), data: error("40", "Wrong username or password"),
}); });
@@ -276,7 +288,7 @@ describe("Subsonic", () => {
describe("when the backend is generic subsonic", () => { describe("when the backend is generic subsonic", () => {
it("should be able to generate a token and then login using it", async () => { it("should be able to generate a token and then login using it", async () => {
const type = `subsonic-clone-${uuid()}`; const type = `subsonic-clone-${uuid()}`;
(axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); mockAxios.mockResolvedValue(ok(pingJson({ type })));
const credentials = { username, password, type: "foo", bearer: undefined }; const credentials = { username, password, type: "foo", bearer: undefined };
const originalToken = asToken(credentials) const originalToken = asToken(credentials)
@@ -292,8 +304,11 @@ describe("Subsonic", () => {
expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type }) expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type })
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { expect(mockAxios).toHaveBeenCalledWith({
params: asURLSearchParams(authParamsPlusJson), method:'get',
baseURL,
url: `/rest/ping.view`,
params: authParamsPlusJson,
headers, headers,
}); });
}); });
@@ -303,8 +318,9 @@ describe("Subsonic", () => {
it("should login to nd and get the nd bearer token", async () => { it("should login to nd and get the nd bearer token", async () => {
const navidromeToken = `nd-${uuid()}`; const navidromeToken = `nd-${uuid()}`;
(axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); mockAxios
(axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); .mockResolvedValueOnce(ok(pingJson({ type: "navidrome" })))
.mockResolvedValueOnce(ok({ token: navidromeToken }));
const credentials = { username, password, type: "navidrome", bearer: undefined }; const credentials = { username, password, type: "navidrome", bearer: undefined };
const originalToken = asToken(credentials) const originalToken = asToken(credentials)
@@ -320,13 +336,21 @@ describe("Subsonic", () => {
expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken })
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { expect(mockAxios).toHaveBeenCalledWith({
params: asURLSearchParams(authParamsPlusJson), method: 'get',
baseURL,
url: `/rest/ping.view`,
params: authParamsPlusJson,
headers, headers,
}); });
expect(axios.post).toHaveBeenCalledWith(`${url}/auth/login`, { expect(mockAxios).toHaveBeenCalledWith({
username, method: 'post',
password, baseURL,
url: `/auth/login`,
data: {
username,
password,
}
}); });
}); });
}); });
@@ -334,7 +358,7 @@ describe("Subsonic", () => {
describe("when the credentials are not valid", () => { describe("when the credentials are not valid", () => {
it("should be able to generate a token and then login using it", async () => { it("should be able to generate a token and then login using it", async () => {
(axios.get as jest.Mock).mockResolvedValue({ mockAxios.mockResolvedValue({
status: 200, status: 200,
data: error("40", "Wrong username or password"), data: error("40", "Wrong username or password"),
}); });

File diff suppressed because it is too large Load Diff