mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
tests working
This commit is contained in:
89
src/http.ts
Normal file
89
src/http.ts
Normal 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
113
src/subsonic/http.ts
Normal 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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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
286
tests/http.test.ts
Normal 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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -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
Reference in New Issue
Block a user