mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
URN for image info (#78)
* Allow music service to return a URN identifying cover art for an entity * Fix bug with playlist cover art rending same album multiple times
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"@types/jws": "^3.2.4",
|
"@types/jws": "^3.2.4",
|
||||||
"@types/morgan": "^1.9.3",
|
"@types/morgan": "^1.9.3",
|
||||||
"@types/node": "^16.7.13",
|
"@types/node": "^16.7.13",
|
||||||
|
"@types/randomstring": "^1.1.8",
|
||||||
"@types/sharp": "^0.28.6",
|
"@types/sharp": "^0.28.6",
|
||||||
"@types/underscore": "^1.11.3",
|
"@types/underscore": "^1.11.3",
|
||||||
"@types/uuid": "^8.3.1",
|
"@types/uuid": "^8.3.1",
|
||||||
@@ -27,11 +28,13 @@
|
|||||||
"libxmljs2": "^0.28.0",
|
"libxmljs2": "^0.28.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"node-html-parser": "^4.1.4",
|
"node-html-parser": "^4.1.4",
|
||||||
|
"randomstring": "^1.2.1",
|
||||||
"sharp": "^0.29.1",
|
"sharp": "^0.29.1",
|
||||||
"soap": "^0.42.0",
|
"soap": "^0.42.0",
|
||||||
"ts-md5": "^1.2.9",
|
"ts-md5": "^1.2.9",
|
||||||
"typescript": "^4.4.2",
|
"typescript": "^4.4.2",
|
||||||
"underscore": "^1.13.1",
|
"underscore": "^1.13.1",
|
||||||
|
"urn-lib": "^2.0.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"winston": "^3.3.3"
|
"winston": "^3.3.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ const app = server(
|
|||||||
applyContextPath: true,
|
applyContextPath: true,
|
||||||
logRequests: true,
|
logRequests: true,
|
||||||
version,
|
version,
|
||||||
tokenSigner: jwtSigner(config.secret)
|
tokenSigner: jwtSigner(config.secret),
|
||||||
|
externalImageResolver: artistImageFetcher
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
90
src/burn.ts
Normal file
90
src/burn.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import _ from "underscore";
|
||||||
|
import { createUrnUtil } from "urn-lib";
|
||||||
|
import randomstring from "randomstring";
|
||||||
|
|
||||||
|
import jwsEncryption from "./encryption";
|
||||||
|
|
||||||
|
const BURN = createUrnUtil("bnb", {
|
||||||
|
components: ["system", "resource"],
|
||||||
|
separator: ":",
|
||||||
|
allowEmpty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BUrn = {
|
||||||
|
system: string;
|
||||||
|
resource: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FORMAT_OPTS = {
|
||||||
|
shorthand: false,
|
||||||
|
encrypt: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORTHAND_MAPPINGS: Record<string, string> = {
|
||||||
|
"internal" : "i",
|
||||||
|
"external": "e",
|
||||||
|
"subsonic": "s",
|
||||||
|
"navidrome": "n",
|
||||||
|
"encrypted": "x"
|
||||||
|
}
|
||||||
|
const REVERSE_SHORTHAND_MAPPINGS: Record<string, string> = Object.keys(SHORTHAND_MAPPINGS).reduce((ret, key) => {
|
||||||
|
ret[SHORTHAND_MAPPINGS[key] as unknown as string] = key;
|
||||||
|
return ret;
|
||||||
|
}, {} as Record<string, string>)
|
||||||
|
if(SHORTHAND_MAPPINGS.length != REVERSE_SHORTHAND_MAPPINGS.length) {
|
||||||
|
throw `Invalid SHORTHAND_MAPPINGS, must be duplicate!`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BURN_SALT = randomstring.generate(5);
|
||||||
|
const encryptor = jwsEncryption(BURN_SALT);
|
||||||
|
|
||||||
|
export const format = (
|
||||||
|
burn: BUrn,
|
||||||
|
opts: Partial<{ shorthand: boolean; encrypt: boolean }> = {}
|
||||||
|
): string => {
|
||||||
|
const o = { ...DEFAULT_FORMAT_OPTS, ...opts }
|
||||||
|
let toBurn = burn;
|
||||||
|
if(o.shorthand) {
|
||||||
|
toBurn = {
|
||||||
|
...toBurn,
|
||||||
|
system: SHORTHAND_MAPPINGS[toBurn.system] || toBurn.system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(o.encrypt) {
|
||||||
|
const encryptedToBurn = {
|
||||||
|
system: "encrypted",
|
||||||
|
resource: encryptor.encrypt(BURN.format(toBurn))
|
||||||
|
}
|
||||||
|
return format(encryptedToBurn, { ...opts, encrypt: false })
|
||||||
|
} else {
|
||||||
|
return BURN.format(toBurn);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatForURL = (burn: BUrn) => {
|
||||||
|
if(burn.system == "external") return format(burn, { shorthand: true, encrypt: true })
|
||||||
|
else return format(burn, { shorthand: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parse = (burn: string): BUrn => {
|
||||||
|
const result = BURN.parse(burn)!;
|
||||||
|
const validationErrors = BURN.validate(result) || [];
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
throw new Error(`Invalid burn: '${burn}'`);
|
||||||
|
}
|
||||||
|
const system = result.system as string;
|
||||||
|
const x = {
|
||||||
|
system: REVERSE_SHORTHAND_MAPPINGS[system] || system,
|
||||||
|
resource: result.resource as string,
|
||||||
|
};
|
||||||
|
if(x.system == "encrypted") {
|
||||||
|
return parse(encryptor.decrypt(x.resource));
|
||||||
|
} else {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSystem(urn: BUrn, system: string): BUrn {
|
||||||
|
if (urn.system != system) throw `Unsupported urn: '${format(urn)}'`;
|
||||||
|
else return urn;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { BUrn } from "./burn";
|
||||||
|
|
||||||
export type Credentials = { username: string; password: string };
|
export type Credentials = { username: string; password: string };
|
||||||
|
|
||||||
export function isSuccess(
|
export function isSuccess(
|
||||||
@@ -25,24 +27,12 @@ export type AuthFailure = {
|
|||||||
export type ArtistSummary = {
|
export type ArtistSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
image: BUrn | undefined;
|
||||||
|
|
||||||
export type Images = {
|
|
||||||
small: string | undefined;
|
|
||||||
medium: string | undefined;
|
|
||||||
large: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NO_IMAGES: Images = {
|
|
||||||
small: undefined,
|
|
||||||
medium: undefined,
|
|
||||||
large: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SimilarArtist = ArtistSummary & { inLibrary: boolean };
|
export type SimilarArtist = ArtistSummary & { inLibrary: boolean };
|
||||||
|
|
||||||
export type Artist = ArtistSummary & {
|
export type Artist = ArtistSummary & {
|
||||||
image: Images
|
|
||||||
albums: AlbumSummary[];
|
albums: AlbumSummary[];
|
||||||
similarArtists: SimilarArtist[]
|
similarArtists: SimilarArtist[]
|
||||||
};
|
};
|
||||||
@@ -52,7 +42,7 @@ export type AlbumSummary = {
|
|||||||
name: string;
|
name: string;
|
||||||
year: string | undefined;
|
year: string | undefined;
|
||||||
genre: Genre | undefined;
|
genre: Genre | undefined;
|
||||||
coverArt: string | undefined;
|
coverArt: BUrn | undefined;
|
||||||
|
|
||||||
artistName: string | undefined;
|
artistName: string | undefined;
|
||||||
artistId: string | undefined;
|
artistId: string | undefined;
|
||||||
@@ -77,7 +67,7 @@ export type Track = {
|
|||||||
duration: number;
|
duration: number;
|
||||||
number: number | undefined;
|
number: number | undefined;
|
||||||
genre: Genre | undefined;
|
genre: Genre | undefined;
|
||||||
coverArt: string | undefined;
|
coverArt: BUrn | undefined;
|
||||||
album: AlbumSummary;
|
album: AlbumSummary;
|
||||||
artist: ArtistSummary;
|
artist: ArtistSummary;
|
||||||
rating: Rating;
|
rating: Rating;
|
||||||
@@ -117,6 +107,7 @@ export type AlbumQuery = Paging & {
|
|||||||
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
||||||
id: it.id,
|
id: it.id,
|
||||||
name: it.name,
|
name: it.name,
|
||||||
|
image: it.image
|
||||||
});
|
});
|
||||||
|
|
||||||
export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
||||||
@@ -184,7 +175,7 @@ export interface MusicLibrary {
|
|||||||
range: string | undefined;
|
range: string | undefined;
|
||||||
}): Promise<TrackStream>;
|
}): Promise<TrackStream>;
|
||||||
rate(trackId: string, rating: Rating): Promise<boolean>;
|
rate(trackId: string, rating: Rating): Promise<boolean>;
|
||||||
coverArt(id: string, size?: number): Promise<CoverArt | undefined>;
|
coverArt(coverArtURN: BUrn, size?: number): Promise<CoverArt | undefined>;
|
||||||
nowPlaying(id: string): Promise<boolean>
|
nowPlaying(id: string): Promise<boolean>
|
||||||
scrobble(id: string): Promise<boolean>
|
scrobble(id: string): Promise<boolean>
|
||||||
searchArtists(query: string): Promise<ArtistSummary[]>;
|
searchArtists(query: string): Promise<ArtistSummary[]>;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { randomBytes } from "crypto";
|
|
||||||
|
|
||||||
const randomString = () => randomBytes(32).toString('hex')
|
|
||||||
|
|
||||||
export default randomString
|
|
||||||
|
|
||||||
@@ -35,6 +35,8 @@ import _, { shuffle } from "underscore";
|
|||||||
import morgan from "morgan";
|
import morgan from "morgan";
|
||||||
import { takeWithRepeats } from "./utils";
|
import { takeWithRepeats } from "./utils";
|
||||||
import { jwtSigner, Signer } from "./encryption";
|
import { jwtSigner, Signer } from "./encryption";
|
||||||
|
import { parse } from "./burn";
|
||||||
|
import { axiosImageFetcher, ImageFetcher } from "./subsonic";
|
||||||
|
|
||||||
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ export type ServerOpts = {
|
|||||||
logRequests: boolean;
|
logRequests: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
tokenSigner: Signer;
|
tokenSigner: Signer;
|
||||||
|
externalImageResolver: ImageFetcher;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||||
@@ -98,6 +101,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
|
|||||||
logRequests: false,
|
logRequests: false,
|
||||||
version: "v?",
|
version: "v?",
|
||||||
tokenSigner: jwtSigner(`bonob-${uuid()}`),
|
tokenSigner: jwtSigner(`bonob-${uuid()}`),
|
||||||
|
externalImageResolver: axiosImageFetcher
|
||||||
};
|
};
|
||||||
|
|
||||||
function server(
|
function server(
|
||||||
@@ -527,11 +531,11 @@ function server(
|
|||||||
"centre",
|
"centre",
|
||||||
];
|
];
|
||||||
|
|
||||||
app.get("/art/:ids/size/:size", (req, res) => {
|
app.get("/art/:burns/size/:size", (req, res) => {
|
||||||
const authToken = accessTokens.authTokenFor(
|
const authToken = accessTokens.authTokenFor(
|
||||||
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
|
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
|
||||||
);
|
);
|
||||||
const ids = req.params["ids"]!.split("&");
|
const urns = req.params["burns"]!.split("&").map(parse);
|
||||||
const size = Number.parseInt(req.params["size"]!);
|
const size = Number.parseInt(req.params["size"]!);
|
||||||
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
@@ -542,7 +546,13 @@ function server(
|
|||||||
|
|
||||||
return musicService
|
return musicService
|
||||||
.login(authToken)
|
.login(authToken)
|
||||||
.then((it) => Promise.all(ids.map((id) => it.coverArt(id, size))))
|
.then((musicLibrary) => Promise.all(urns.map((it) => {
|
||||||
|
if(it.system == "external") {
|
||||||
|
return serverOpts.externalImageResolver(it.resource);
|
||||||
|
} else {
|
||||||
|
return musicLibrary.coverArt(it, size);
|
||||||
|
}
|
||||||
|
})))
|
||||||
.then((coverArts) => coverArts.filter((it) => it))
|
.then((coverArts) => coverArts.filter((it) => it))
|
||||||
.then(shuffle)
|
.then(shuffle)
|
||||||
.then((coverArts) => {
|
.then((coverArts) => {
|
||||||
@@ -580,7 +590,7 @@ function server(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
logger.error(`Failed fetching image ${ids.join("&")}/size/${size}`, {
|
logger.error(`Failed fetching image ${urns.join("&")}/size/${size}`, {
|
||||||
cause: e,
|
cause: e,
|
||||||
});
|
});
|
||||||
return res.status(500).send();
|
return res.status(500).send();
|
||||||
|
|||||||
34
src/smapi.ts
34
src/smapi.ts
@@ -3,6 +3,9 @@ import { Express, Request } from "express";
|
|||||||
import { listen } from "soap";
|
import { listen } from "soap";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
import { option as O } from "fp-ts";
|
||||||
|
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
|
||||||
import { LinkCodes } from "./link_codes";
|
import { LinkCodes } from "./link_codes";
|
||||||
@@ -23,8 +26,9 @@ import { Clock } from "./clock";
|
|||||||
import { URLBuilder } from "./url_builder";
|
import { URLBuilder } from "./url_builder";
|
||||||
import { asLANGs, I8N } from "./i8n";
|
import { asLANGs, I8N } from "./i8n";
|
||||||
import { ICON, iconForGenre } from "./icon";
|
import { ICON, iconForGenre } from "./icon";
|
||||||
import { uniq } from "underscore";
|
import _, { uniq } from "underscore";
|
||||||
import { pSigner, Signer } from "./encryption";
|
import { pSigner, Signer } from "./encryption";
|
||||||
|
import { BUrn, formatForURL } from "./burn";
|
||||||
|
|
||||||
export const LOGIN_ROUTE = "/login";
|
export const LOGIN_ROUTE = "/login";
|
||||||
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||||
@@ -245,25 +249,26 @@ export const playlistAlbumArtURL = (
|
|||||||
bonobUrl: URLBuilder,
|
bonobUrl: URLBuilder,
|
||||||
playlist: Playlist
|
playlist: Playlist
|
||||||
) => {
|
) => {
|
||||||
const ids = uniq(
|
const burns: BUrn[] = uniq(playlist.entries.filter(it => it.coverArt != undefined), it => it.album.id).map((it) => it.coverArt!);
|
||||||
playlist.entries.map((it) => it.coverArt).filter((it) => it)
|
console.log(`### playlist ${playlist.name} burns -> ${JSON.stringify(burns)}`)
|
||||||
);
|
if (burns.length == 0) {
|
||||||
if (ids.length == 0) {
|
|
||||||
return iconArtURI(bonobUrl, "error");
|
return iconArtURI(bonobUrl, "error");
|
||||||
} else {
|
} else {
|
||||||
return bonobUrl.append({
|
return bonobUrl.append({
|
||||||
pathname: `/art/${ids.slice(0, 9).join("&")}/size/180`,
|
pathname: `/art/${burns.slice(0, 9).map(it => encodeURIComponent(formatForURL(it))).join("&")}/size/180`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultAlbumArtURI = (
|
export const defaultAlbumArtURI = (
|
||||||
bonobUrl: URLBuilder,
|
bonobUrl: URLBuilder,
|
||||||
{ coverArt }: { coverArt: string | undefined }
|
{ coverArt }: { coverArt: BUrn | undefined }
|
||||||
) =>
|
) => pipe(
|
||||||
coverArt
|
coverArt,
|
||||||
? bonobUrl.append({ pathname: `/art/${coverArt}/size/180` })
|
O.fromNullable,
|
||||||
: iconArtURI(bonobUrl, "vinyl");
|
O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })),
|
||||||
|
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
|
||||||
|
);
|
||||||
|
|
||||||
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
|
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
|
||||||
bonobUrl.append({
|
bonobUrl.append({
|
||||||
@@ -273,7 +278,12 @@ export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
|
|||||||
export const defaultArtistArtURI = (
|
export const defaultArtistArtURI = (
|
||||||
bonobUrl: URLBuilder,
|
bonobUrl: URLBuilder,
|
||||||
artist: ArtistSummary
|
artist: ArtistSummary
|
||||||
) => bonobUrl.append({ pathname: `/art/artist:${artist.id}/size/180` });
|
) => pipe(
|
||||||
|
artist.image,
|
||||||
|
O.fromNullable,
|
||||||
|
O.map(it => bonobUrl.append({ pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180` })),
|
||||||
|
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
|
||||||
|
);
|
||||||
|
|
||||||
export const sonosifyMimeType = (mimeType: string) =>
|
export const sonosifyMimeType = (mimeType: string) =>
|
||||||
mimeType == "audio/x-flac" ? "audio/flac" : mimeType;
|
mimeType == "audio/x-flac" ? "audio/flac" : mimeType;
|
||||||
|
|||||||
309
src/subsonic.ts
309
src/subsonic.ts
@@ -7,20 +7,18 @@ import {
|
|||||||
Credentials,
|
Credentials,
|
||||||
MusicService,
|
MusicService,
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
|
||||||
ArtistSummary,
|
|
||||||
Result,
|
Result,
|
||||||
slice2,
|
slice2,
|
||||||
AlbumQuery,
|
AlbumQuery,
|
||||||
ArtistQuery,
|
ArtistQuery,
|
||||||
MusicLibrary,
|
MusicLibrary,
|
||||||
Images,
|
|
||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
Genre,
|
Genre,
|
||||||
Track,
|
Track,
|
||||||
CoverArt,
|
CoverArt,
|
||||||
Rating,
|
Rating,
|
||||||
AlbumQueryType,
|
AlbumQueryType,
|
||||||
|
Artist,
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
@@ -28,9 +26,11 @@ import fse from "fs-extra";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import axios, { AxiosRequestConfig } from "axios";
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
import randomString from "./random_string";
|
import randomstring from "randomstring";
|
||||||
import { b64Encode, b64Decode } from "./b64";
|
import { b64Encode, b64Decode } from "./b64";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
import { assertSystem, BUrn } from "./burn";
|
||||||
|
import { artist } from "./smapi";
|
||||||
|
|
||||||
export const BROWSER_HEADERS = {
|
export const BROWSER_HEADERS = {
|
||||||
accept:
|
accept:
|
||||||
@@ -46,7 +46,7 @@ export const t = (password: string, s: string) =>
|
|||||||
Md5.hashStr(`${password}${s}`);
|
Md5.hashStr(`${password}${s}`);
|
||||||
|
|
||||||
export const t_and_s = (password: string) => {
|
export const t_and_s = (password: string) => {
|
||||||
const s = randomString();
|
const s = randomstring.generate();
|
||||||
return {
|
return {
|
||||||
t: t(password, s),
|
t: t(password, s),
|
||||||
s,
|
s,
|
||||||
@@ -55,20 +55,18 @@ export const t_and_s = (password: string) => {
|
|||||||
|
|
||||||
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
|
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
|
||||||
|
|
||||||
export const isDodgyImage = (url: string) => url.endsWith(DODGY_IMAGE_NAME);
|
export const isValidImage = (url: string | undefined) =>
|
||||||
|
url != undefined && !url.endsWith(DODGY_IMAGE_NAME);
|
||||||
|
|
||||||
export const validate = (url: string | undefined) =>
|
type SubsonicEnvelope = {
|
||||||
url && !isDodgyImage(url) ? url : undefined;
|
|
||||||
|
|
||||||
export type SubsonicEnvelope = {
|
|
||||||
"subsonic-response": SubsonicResponse;
|
"subsonic-response": SubsonicResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubsonicResponse = {
|
type SubsonicResponse = {
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type album = {
|
type album = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
artist: string | undefined;
|
artist: string | undefined;
|
||||||
@@ -78,73 +76,75 @@ export type album = {
|
|||||||
year: string | undefined;
|
year: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type artistSummary = {
|
type artist = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
albumCount: number;
|
albumCount: number;
|
||||||
artistImageUrl: string | undefined;
|
artistImageUrl: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetArtistsResponse = SubsonicResponse & {
|
type GetArtistsResponse = SubsonicResponse & {
|
||||||
artists: {
|
artists: {
|
||||||
index: {
|
index: {
|
||||||
artist: artistSummary[];
|
artist: artist[];
|
||||||
name: string;
|
name: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetAlbumListResponse = SubsonicResponse & {
|
type GetAlbumListResponse = SubsonicResponse & {
|
||||||
albumList2: {
|
albumList2: {
|
||||||
album: album[];
|
album: album[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type genre = {
|
type genre = {
|
||||||
songCount: number;
|
songCount: number;
|
||||||
albumCount: number;
|
albumCount: number;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetGenresResponse = SubsonicResponse & {
|
type GetGenresResponse = SubsonicResponse & {
|
||||||
genres: {
|
genres: {
|
||||||
genre: genre[];
|
genre: genre[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubsonicError = SubsonicResponse & {
|
type SubsonicError = SubsonicResponse & {
|
||||||
error: {
|
error: {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type artistInfo = {
|
export type images = {
|
||||||
biography: string | undefined;
|
|
||||||
musicBrainzId: string | undefined;
|
|
||||||
lastFmUrl: string | undefined;
|
|
||||||
smallImageUrl: string | undefined;
|
smallImageUrl: string | undefined;
|
||||||
mediumImageUrl: string | undefined;
|
mediumImageUrl: string | undefined;
|
||||||
largeImageUrl: string | undefined;
|
largeImageUrl: string | undefined;
|
||||||
similarArtist: artistSummary[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArtistInfo = {
|
type artistInfo = images & {
|
||||||
image: Images;
|
biography: string | undefined;
|
||||||
similarArtist: (ArtistSummary & { inLibrary: boolean })[];
|
musicBrainzId: string | undefined;
|
||||||
|
lastFmUrl: string | undefined;
|
||||||
|
similarArtist: artist[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetArtistInfoResponse = SubsonicResponse & {
|
type ArtistSummary = IdName & {
|
||||||
|
image: BUrn | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetArtistInfoResponse = SubsonicResponse & {
|
||||||
artistInfo2: artistInfo;
|
artistInfo2: artistInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetArtistResponse = SubsonicResponse & {
|
type GetArtistResponse = SubsonicResponse & {
|
||||||
artist: artistSummary & {
|
artist: artist & {
|
||||||
album: album[];
|
album: album[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type song = {
|
type song = {
|
||||||
id: string;
|
id: string;
|
||||||
parent: string | undefined;
|
parent: string | undefined;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -166,18 +166,18 @@ export type song = {
|
|||||||
starred: string | undefined;
|
starred: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetAlbumResponse = {
|
type GetAlbumResponse = {
|
||||||
album: album & {
|
album: album & {
|
||||||
song: song[];
|
song: song[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type playlist = {
|
type playlist = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetPlaylistResponse = {
|
type GetPlaylistResponse = {
|
||||||
playlist: {
|
playlist: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -185,32 +185,32 @@ export type GetPlaylistResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetPlaylistsResponse = {
|
type GetPlaylistsResponse = {
|
||||||
playlists: { playlist: playlist[] };
|
playlists: { playlist: playlist[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSimilarSongsResponse = {
|
type GetSimilarSongsResponse = {
|
||||||
similarSongs2: { song: song[] };
|
similarSongs2: { song: song[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetTopSongsResponse = {
|
type GetTopSongsResponse = {
|
||||||
topSongs: { song: song[] };
|
topSongs: { song: song[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSongResponse = {
|
type GetSongResponse = {
|
||||||
song: song;
|
song: song;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetStarredResponse = {
|
type GetStarredResponse = {
|
||||||
starred2: {
|
starred2: {
|
||||||
song: song[];
|
song: song[];
|
||||||
album: album[];
|
album: album[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Search3Response = SubsonicResponse & {
|
type Search3Response = SubsonicResponse & {
|
||||||
searchResult3: {
|
searchResult3: {
|
||||||
artist: artistSummary[];
|
artist: artist[];
|
||||||
album: album[];
|
album: album[];
|
||||||
song: song[];
|
song: song[];
|
||||||
};
|
};
|
||||||
@@ -222,31 +222,44 @@ export function isError(
|
|||||||
return (subsonicResponse as SubsonicError).error !== undefined;
|
return (subsonicResponse as SubsonicError).error !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const splitCoverArtId = (coverArt: string): [string, string] => {
|
type IdName = {
|
||||||
const parts = coverArt.split(":").filter((it) => it.length > 0);
|
|
||||||
if (parts.length < 2) throw `'${coverArt}' is an invalid coverArt id'`;
|
|
||||||
return [parts[0]!, parts.slice(1).join(":")];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IdName = {
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type getAlbumListParams = {
|
const coverArtURN = (coverArt: string | undefined): BUrn | undefined => pipe(
|
||||||
type: string;
|
coverArt,
|
||||||
size?: number;
|
O.fromNullable,
|
||||||
offet?: number;
|
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
||||||
fromYear?: string;
|
O.getOrElseW(() => undefined)
|
||||||
toYear?: string;
|
)
|
||||||
genre?: string;
|
|
||||||
|
export const artistImageURN = (
|
||||||
|
spec: Partial<{
|
||||||
|
artistId: string | undefined;
|
||||||
|
artistImageURL: string | undefined;
|
||||||
|
}>
|
||||||
|
): BUrn | undefined => {
|
||||||
|
const deets = {
|
||||||
|
artistId: undefined,
|
||||||
|
artistImageURL: undefined,
|
||||||
|
...spec,
|
||||||
|
};
|
||||||
|
if (deets.artistImageURL && isValidImage(deets.artistImageURL)) {
|
||||||
|
return {
|
||||||
|
system: "external",
|
||||||
|
resource: deets.artistImageURL
|
||||||
|
};
|
||||||
|
} else if (artistIsInLibrary(deets.artistId)) {
|
||||||
|
return {
|
||||||
|
system: "subsonic",
|
||||||
|
resource: `art:${deets.artistId!}`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAX_ALBUM_LIST = 500;
|
|
||||||
|
|
||||||
const maybeAsCoverArt = (coverArt: string | undefined) =>
|
|
||||||
coverArt ? `coverArt:${coverArt}` : undefined;
|
|
||||||
|
|
||||||
export const asTrack = (album: Album, song: song): Track => ({
|
export const asTrack = (album: Album, song: song): Track => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
name: song.title,
|
name: song.title,
|
||||||
@@ -254,11 +267,12 @@ export const asTrack = (album: Album, song: song): Track => ({
|
|||||||
duration: song.duration || 0,
|
duration: song.duration || 0,
|
||||||
number: song.track || 0,
|
number: song.track || 0,
|
||||||
genre: maybeAsGenre(song.genre),
|
genre: maybeAsGenre(song.genre),
|
||||||
coverArt: maybeAsCoverArt(song.coverArt),
|
coverArt: coverArtURN(song.coverArt),
|
||||||
album,
|
album,
|
||||||
artist: {
|
artist: {
|
||||||
id: `${song.artistId!}`,
|
id: `${song.artistId!}`,
|
||||||
name: song.artist!,
|
name: song.artist!,
|
||||||
|
image: artistImageURN({ artistId: song.artistId }),
|
||||||
},
|
},
|
||||||
rating: {
|
rating: {
|
||||||
love: song.starred != undefined,
|
love: song.starred != undefined,
|
||||||
@@ -276,7 +290,7 @@ const asAlbum = (album: album): Album => ({
|
|||||||
genre: maybeAsGenre(album.genre),
|
genre: maybeAsGenre(album.genre),
|
||||||
artistId: album.artistId,
|
artistId: album.artistId,
|
||||||
artistName: album.artist,
|
artistName: album.artist,
|
||||||
coverArt: maybeAsCoverArt(album.coverArt),
|
coverArt: coverArtURN(album.coverArt),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const asGenre = (genreName: string) => ({
|
export const asGenre = (genreName: string) => ({
|
||||||
@@ -294,8 +308,8 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
|||||||
|
|
||||||
export type StreamClientApplication = (track: Track) => string;
|
export type StreamClientApplication = (track: Track) => string;
|
||||||
|
|
||||||
export const DEFAULT_CLIENT_APPLICATION = "bonob";
|
const DEFAULT_CLIENT_APPLICATION = "bonob";
|
||||||
export const USER_AGENT = "bonob";
|
const USER_AGENT = "bonob";
|
||||||
|
|
||||||
export const DEFAULT: StreamClientApplication = (_: Track) =>
|
export const DEFAULT: StreamClientApplication = (_: Track) =>
|
||||||
DEFAULT_CLIENT_APPLICATION;
|
DEFAULT_CLIENT_APPLICATION;
|
||||||
@@ -366,6 +380,9 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
|||||||
starred: "highest",
|
starred: "highest",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const artistIsInLibrary = (artistId: string | undefined) =>
|
||||||
|
artistId != undefined && artistId != "-1";
|
||||||
|
|
||||||
export class Subsonic implements MusicService {
|
export class Subsonic implements MusicService {
|
||||||
url: string;
|
url: string;
|
||||||
streamClientApplication: StreamClientApplication;
|
streamClientApplication: StreamClientApplication;
|
||||||
@@ -400,7 +417,8 @@ export class Subsonic implements MusicService {
|
|||||||
"User-Agent": USER_AGENT,
|
"User-Agent": USER_AGENT,
|
||||||
},
|
},
|
||||||
...config,
|
...config,
|
||||||
}).catch(e => {
|
})
|
||||||
|
.catch((e) => {
|
||||||
throw `Subsonic failed with: ${e}`;
|
throw `Subsonic failed with: ${e}`;
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -425,9 +443,7 @@ export class Subsonic implements MusicService {
|
|||||||
generateToken = async (credentials: Credentials) =>
|
generateToken = async (credentials: Credentials) =>
|
||||||
this.getJSON(credentials, "/rest/ping.view")
|
this.getJSON(credentials, "/rest/ping.view")
|
||||||
.then(() => ({
|
.then(() => ({
|
||||||
authToken: b64Encode(
|
authToken: b64Encode(JSON.stringify(credentials)),
|
||||||
JSON.stringify(credentials)
|
|
||||||
),
|
|
||||||
userId: credentials.username,
|
userId: credentials.username,
|
||||||
nickname: credentials.username,
|
nickname: credentials.username,
|
||||||
}))
|
}))
|
||||||
@@ -437,7 +453,7 @@ export class Subsonic implements MusicService {
|
|||||||
|
|
||||||
getArtists = (
|
getArtists = (
|
||||||
credentials: Credentials
|
credentials: Credentials
|
||||||
): Promise<(IdName & { albumCount: number })[]> =>
|
): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> =>
|
||||||
this.getJSON<GetArtistsResponse>(credentials, "/rest/getArtists")
|
this.getJSON<GetArtistsResponse>(credentials, "/rest/getArtists")
|
||||||
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
|
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
|
||||||
.then((artists) =>
|
.then((artists) =>
|
||||||
@@ -445,26 +461,46 @@ export class Subsonic implements MusicService {
|
|||||||
id: `${artist.id}`,
|
id: `${artist.id}`,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
albumCount: artist.albumCount,
|
albumCount: artist.albumCount,
|
||||||
|
image: artistImageURN({
|
||||||
|
artistId: artist.id,
|
||||||
|
artistImageURL: artist.artistImageUrl,
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
getArtistInfo = (credentials: Credentials, id: string): Promise<ArtistInfo> =>
|
getArtistInfo = (
|
||||||
|
credentials: Credentials,
|
||||||
|
id: string
|
||||||
|
): Promise<{
|
||||||
|
similarArtist: (ArtistSummary & { inLibrary: boolean })[];
|
||||||
|
images: {
|
||||||
|
s: string | undefined;
|
||||||
|
m: string | undefined;
|
||||||
|
l: string | undefined;
|
||||||
|
};
|
||||||
|
}> =>
|
||||||
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo2", {
|
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo2", {
|
||||||
id,
|
id,
|
||||||
count: 50,
|
count: 50,
|
||||||
includeNotPresent: true,
|
includeNotPresent: true,
|
||||||
}).then((it) => ({
|
})
|
||||||
image: {
|
.then((it) => it.artistInfo2)
|
||||||
small: validate(it.artistInfo2.smallImageUrl),
|
.then((it) => ({
|
||||||
medium: validate(it.artistInfo2.mediumImageUrl),
|
images: {
|
||||||
large: validate(it.artistInfo2.largeImageUrl),
|
s: it.smallImageUrl,
|
||||||
},
|
m: it.mediumImageUrl,
|
||||||
similarArtist: (it.artistInfo2.similarArtist || []).map((artist) => ({
|
l: it.largeImageUrl,
|
||||||
id: `${artist.id}`,
|
},
|
||||||
name: artist.name,
|
similarArtist: (it.similarArtist || []).map((artist) => ({
|
||||||
inLibrary: artist.id != "-1",
|
id: `${artist.id}`,
|
||||||
})),
|
name: artist.name,
|
||||||
}));
|
inLibrary: artistIsInLibrary(artist.id),
|
||||||
|
image: artistImageURN({
|
||||||
|
artistId: artist.id,
|
||||||
|
artistImageURL: artist.artistImageUrl,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
getAlbum = (credentials: Credentials, id: string): Promise<Album> =>
|
getAlbum = (credentials: Credentials, id: string): Promise<Album> =>
|
||||||
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
||||||
@@ -476,13 +512,15 @@ export class Subsonic implements MusicService {
|
|||||||
genre: maybeAsGenre(album.genre),
|
genre: maybeAsGenre(album.genre),
|
||||||
artistId: album.artistId,
|
artistId: album.artistId,
|
||||||
artistName: album.artist,
|
artistName: album.artist,
|
||||||
coverArt: maybeAsCoverArt(album.coverArt),
|
coverArt: coverArtURN(album.coverArt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
getArtist = (
|
getArtist = (
|
||||||
credentials: Credentials,
|
credentials: Credentials,
|
||||||
id: string
|
id: string
|
||||||
): Promise<IdName & { albums: AlbumSummary[] }> =>
|
): Promise<
|
||||||
|
IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] }
|
||||||
|
> =>
|
||||||
this.getJSON<GetArtistResponse>(credentials, "/rest/getArtist", {
|
this.getJSON<GetArtistResponse>(credentials, "/rest/getArtist", {
|
||||||
id,
|
id,
|
||||||
})
|
})
|
||||||
@@ -490,6 +528,7 @@ export class Subsonic implements MusicService {
|
|||||||
.then((it) => ({
|
.then((it) => ({
|
||||||
id: it.id,
|
id: it.id,
|
||||||
name: it.name,
|
name: it.name,
|
||||||
|
artistImageUrl: it.artistImageUrl,
|
||||||
albums: this.toAlbumSummary(it.album || []),
|
albums: this.toAlbumSummary(it.album || []),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -500,7 +539,15 @@ export class Subsonic implements MusicService {
|
|||||||
]).then(([artist, artistInfo]) => ({
|
]).then(([artist, artistInfo]) => ({
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
image: artistInfo.image,
|
image: artistImageURN({
|
||||||
|
artistId: artist.id,
|
||||||
|
artistImageURL: [
|
||||||
|
artist.artistImageUrl,
|
||||||
|
artistInfo.images.l,
|
||||||
|
artistInfo.images.m,
|
||||||
|
artistInfo.images.s,
|
||||||
|
].find(isValidImage),
|
||||||
|
}),
|
||||||
albums: artist.albums,
|
albums: artist.albums,
|
||||||
similarArtists: artistInfo.similarArtist,
|
similarArtists: artistInfo.similarArtist,
|
||||||
}));
|
}));
|
||||||
@@ -535,7 +582,7 @@ export class Subsonic implements MusicService {
|
|||||||
genre: maybeAsGenre(album.genre),
|
genre: maybeAsGenre(album.genre),
|
||||||
artistId: album.artistId,
|
artistId: album.artistId,
|
||||||
artistName: album.artist,
|
artistName: album.artist,
|
||||||
coverArt: maybeAsCoverArt(album.coverArt),
|
coverArt: coverArtURN(album.coverArt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
search3 = (credentials: Credentials, q: any) =>
|
search3 = (credentials: Credentials, q: any) =>
|
||||||
@@ -586,7 +633,11 @@ export class Subsonic implements MusicService {
|
|||||||
.then(slice2(q))
|
.then(slice2(q))
|
||||||
.then(([page, total]) => ({
|
.then(([page, total]) => ({
|
||||||
total,
|
total,
|
||||||
results: page.map((it) => ({ id: it.id, name: it.name })),
|
results: page.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
image: it.image,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
artist: async (id: string): Promise<Artist> =>
|
artist: async (id: string): Promise<Artist> =>
|
||||||
subsonic.getArtistWithInfo(credentials, id),
|
subsonic.getArtistWithInfo(credentials, id),
|
||||||
@@ -691,63 +742,27 @@ export class Subsonic implements MusicService {
|
|||||||
stream: res.data,
|
stream: res.data,
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
coverArt: async (coverArt: string, size?: number) => {
|
coverArt: async (coverArtURN: BUrn, size?: number) =>
|
||||||
const [type, id] = splitCoverArtId(coverArt);
|
Promise.resolve(coverArtURN)
|
||||||
if (type == "coverArt") {
|
.then((it) => assertSystem(it, "subsonic"))
|
||||||
return subsonic
|
.then((it) => it.resource.split(":")[1]!)
|
||||||
.getCoverArt(credentials, id, size)
|
.then((it) =>
|
||||||
.then((res) => ({
|
subsonic.getCoverArt(
|
||||||
contentType: res.headers["content-type"],
|
credentials,
|
||||||
data: Buffer.from(res.data, "binary"),
|
it,
|
||||||
}))
|
size
|
||||||
.catch((e) => {
|
)
|
||||||
logger.error(`Failed getting coverArt ${coverArt}: ${e}`);
|
)
|
||||||
return undefined;
|
.then((res) => ({
|
||||||
});
|
contentType: res.headers["content-type"],
|
||||||
} else {
|
data: Buffer.from(res.data, "binary"),
|
||||||
return subsonic
|
}))
|
||||||
.getArtistWithInfo(credentials, id)
|
.catch((e) => {
|
||||||
.then((artist) => {
|
logger.error(
|
||||||
const albumsWithCoverArt = artist.albums.filter(
|
`Failed getting coverArt for urn:'${coverArtURN}': ${e}`
|
||||||
(it) => it.coverArt
|
);
|
||||||
);
|
return undefined;
|
||||||
if (artist.image.large) {
|
}),
|
||||||
return this.externalImageFetcher(artist.image.large!).then(
|
|
||||||
(image) => {
|
|
||||||
if (image && size) {
|
|
||||||
return sharp(image.data)
|
|
||||||
.resize(size)
|
|
||||||
.toBuffer()
|
|
||||||
.then((resized) => ({
|
|
||||||
contentType: image.contentType,
|
|
||||||
data: resized,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (albumsWithCoverArt.length > 0) {
|
|
||||||
return subsonic
|
|
||||||
.getCoverArt(
|
|
||||||
credentials,
|
|
||||||
splitCoverArtId(albumsWithCoverArt[0]!.coverArt!)[1],
|
|
||||||
size
|
|
||||||
)
|
|
||||||
.then((res) => ({
|
|
||||||
contentType: res.headers["content-type"],
|
|
||||||
data: Buffer.from(res.data, "binary"),
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(`Failed getting coverArt ${coverArt}: ${e}`);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrobble: async (id: string) =>
|
scrobble: async (id: string) =>
|
||||||
subsonic
|
subsonic
|
||||||
.getJSON(credentials, `/rest/scrobble`, {
|
.getJSON(credentials, `/rest/scrobble`, {
|
||||||
@@ -771,6 +786,10 @@ export class Subsonic implements MusicService {
|
|||||||
artists.map((artist) => ({
|
artists.map((artist) => ({
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
|
image: artistImageURN({
|
||||||
|
artistId: artist.id,
|
||||||
|
artistImageURL: artist.artistImageUrl,
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
searchAlbums: async (query: string) =>
|
searchAlbums: async (query: string) =>
|
||||||
@@ -812,7 +831,7 @@ export class Subsonic implements MusicService {
|
|||||||
genre: maybeAsGenre(entry.genre),
|
genre: maybeAsGenre(entry.genre),
|
||||||
artistName: entry.artist,
|
artistName: entry.artist,
|
||||||
artistId: entry.artistId,
|
artistId: entry.artistId,
|
||||||
coverArt: maybeAsCoverArt(entry.coverArt),
|
coverArt: coverArtURN(entry.coverArt),
|
||||||
},
|
},
|
||||||
entry
|
entry
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SonosDevice } from "@svrooij/sonos/lib";
|
import { SonosDevice } from "@svrooij/sonos/lib";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
import randomstring from "randomstring";
|
||||||
|
|
||||||
import { Credentials } from "../src/smapi";
|
import { Credentials } from "../src/smapi";
|
||||||
import { Service, Device } from "../src/sonos";
|
import { Service, Device } from "../src/sonos";
|
||||||
@@ -11,9 +12,12 @@ import {
|
|||||||
artistToArtistSummary,
|
artistToArtistSummary,
|
||||||
PlaylistSummary,
|
PlaylistSummary,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
SimilarArtist,
|
||||||
|
AlbumSummary,
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
import randomString from "../src/random_string";
|
|
||||||
import { b64Encode } from "../src/b64";
|
import { b64Encode } from "../src/b64";
|
||||||
|
import { artistImageURN } from "../src/subsonic";
|
||||||
|
|
||||||
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
||||||
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
||||||
@@ -42,7 +46,7 @@ export function aPlaylistSummary(
|
|||||||
): PlaylistSummary {
|
): PlaylistSummary {
|
||||||
return {
|
return {
|
||||||
id: `playlist-${uuid()}`,
|
id: `playlist-${uuid()}`,
|
||||||
name: `playlistname-${randomString()}`,
|
name: `playlistname-${randomstring.generate()}`,
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,7 @@ export function aPlaylistSummary(
|
|||||||
export function aPlaylist(fields: Partial<Playlist> = {}): Playlist {
|
export function aPlaylist(fields: Partial<Playlist> = {}): Playlist {
|
||||||
return {
|
return {
|
||||||
id: `playlist-${uuid()}`,
|
id: `playlist-${uuid()}`,
|
||||||
name: `playlist-${randomString()}`,
|
name: `playlist-${randomstring.generate()}`,
|
||||||
entries: [aTrack(), aTrack()],
|
entries: [aTrack(), aTrack()],
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
@@ -97,21 +101,34 @@ export function someCredentials(token: string): Credentials {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function aSimilarArtist(
|
||||||
|
fields: Partial<SimilarArtist> = {}
|
||||||
|
): SimilarArtist {
|
||||||
|
const id = fields.id || uuid();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Similar Artist ${id}`,
|
||||||
|
image: artistImageURN({ artistId: id }),
|
||||||
|
inLibrary: true,
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function anArtist(fields: Partial<Artist> = {}): Artist {
|
export function anArtist(fields: Partial<Artist> = {}): Artist {
|
||||||
const id = uuid();
|
const id = fields.id || uuid();
|
||||||
const artist = {
|
const artist = {
|
||||||
id,
|
id,
|
||||||
name: `Artist ${id}`,
|
name: `Artist ${id}`,
|
||||||
albums: [anAlbum(), anAlbum(), anAlbum()],
|
albums: [anAlbum(), anAlbum(), anAlbum()],
|
||||||
image: {
|
image: { system: "subsonic", resource: `art:${id}` },
|
||||||
small: `/artist/art/${id}/small`,
|
|
||||||
medium: `/artist/art/${id}/small`,
|
|
||||||
large: `/artist/art/${id}/large`,
|
|
||||||
},
|
|
||||||
similarArtists: [
|
similarArtists: [
|
||||||
{ id: uuid(), name: "Similar artist1", inLibrary: true },
|
aSimilarArtist({ id: uuid(), name: "Similar artist1", inLibrary: true }),
|
||||||
{ id: uuid(), name: "Similar artist2", inLibrary: true },
|
aSimilarArtist({ id: uuid(), name: "Similar artist2", inLibrary: true }),
|
||||||
{ id: "-1", name: "Artist not in library", inLibrary: false },
|
aSimilarArtist({
|
||||||
|
id: "-1",
|
||||||
|
name: "Artist not in library",
|
||||||
|
inLibrary: false,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
@@ -163,11 +180,11 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
|||||||
album: albumToAlbumSummary(
|
album: albumToAlbumSummary(
|
||||||
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
|
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
|
||||||
),
|
),
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
rating,
|
rating,
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export function anAlbum(fields: Partial<Album> = {}): Album {
|
export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
@@ -177,11 +194,25 @@ export function anAlbum(fields: Partial<Album> = {}): Album {
|
|||||||
genre: randomGenre(),
|
genre: randomGenre(),
|
||||||
year: `19${randomInt(99)}`,
|
year: `19${randomInt(99)}`,
|
||||||
artistId: `Artist ${uuid()}`,
|
artistId: `Artist ${uuid()}`,
|
||||||
artistName: `Artist ${randomString()}`,
|
artistName: `Artist ${randomstring.generate()}`,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary {
|
||||||
|
const id = uuid();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Album ${id}`,
|
||||||
|
year: `19${randomInt(99)}`,
|
||||||
|
genre: randomGenre(),
|
||||||
|
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
|
||||||
|
artistId: `Artist ${uuid()}`,
|
||||||
|
artistName: `Artist ${randomstring.generate()}`,
|
||||||
|
...fields
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const BLONDIE_ID = uuid();
|
export const BLONDIE_ID = uuid();
|
||||||
export const BLONDIE_NAME = "Blondie";
|
export const BLONDIE_NAME = "Blondie";
|
||||||
@@ -196,7 +227,7 @@ export const BLONDIE: Artist = {
|
|||||||
genre: NEW_WAVE,
|
genre: NEW_WAVE,
|
||||||
artistId: BLONDIE_ID,
|
artistId: BLONDIE_ID,
|
||||||
artistName: BLONDIE_NAME,
|
artistName: BLONDIE_NAME,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -205,14 +236,10 @@ export const BLONDIE: Artist = {
|
|||||||
genre: POP_ROCK,
|
genre: POP_ROCK,
|
||||||
artistId: BLONDIE_ID,
|
artistId: BLONDIE_ID,
|
||||||
artistName: BLONDIE_NAME,
|
artistName: BLONDIE_NAME,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
image: {
|
image: { system: "external", resource: "http://localhost:1234/images/blondie.jpg" },
|
||||||
small: undefined,
|
|
||||||
medium: undefined,
|
|
||||||
large: undefined,
|
|
||||||
},
|
|
||||||
similarArtists: [],
|
similarArtists: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,7 +256,7 @@ export const BOB_MARLEY: Artist = {
|
|||||||
genre: REGGAE,
|
genre: REGGAE,
|
||||||
artistId: BOB_MARLEY_ID,
|
artistId: BOB_MARLEY_ID,
|
||||||
artistName: BOB_MARLEY_NAME,
|
artistName: BOB_MARLEY_NAME,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -238,7 +265,7 @@ export const BOB_MARLEY: Artist = {
|
|||||||
genre: REGGAE,
|
genre: REGGAE,
|
||||||
artistId: BOB_MARLEY_ID,
|
artistId: BOB_MARLEY_ID,
|
||||||
artistName: BOB_MARLEY_NAME,
|
artistName: BOB_MARLEY_NAME,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -247,14 +274,10 @@ export const BOB_MARLEY: Artist = {
|
|||||||
genre: SKA,
|
genre: SKA,
|
||||||
artistId: BOB_MARLEY_ID,
|
artistId: BOB_MARLEY_ID,
|
||||||
artistName: BOB_MARLEY_NAME,
|
artistName: BOB_MARLEY_NAME,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
image: {
|
image: { system: "subsonic", resource: BOB_MARLEY_ID },
|
||||||
small: "http://localhost/BOB_MARLEY/sml",
|
|
||||||
medium: "http://localhost/BOB_MARLEY/med",
|
|
||||||
large: "http://localhost/BOB_MARLEY/lge",
|
|
||||||
},
|
|
||||||
similarArtists: [],
|
similarArtists: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,9 +288,8 @@ export const MADONNA: Artist = {
|
|||||||
name: MADONNA_NAME,
|
name: MADONNA_NAME,
|
||||||
albums: [],
|
albums: [],
|
||||||
image: {
|
image: {
|
||||||
small: "http://localhost/MADONNA/sml",
|
system: "external",
|
||||||
medium: undefined,
|
resource: "http://localhost:1234/images/madonna.jpg",
|
||||||
large: "http://localhost/MADONNA/lge",
|
|
||||||
},
|
},
|
||||||
similarArtists: [],
|
similarArtists: [],
|
||||||
};
|
};
|
||||||
@@ -285,7 +307,7 @@ export const METALLICA: Artist = {
|
|||||||
genre: METAL,
|
genre: METAL,
|
||||||
artistId: METALLICA_ID,
|
artistId: METALLICA_ID,
|
||||||
artistName: METALLICA_NAME,
|
artistName: METALLICA_NAME,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -294,18 +316,13 @@ export const METALLICA: Artist = {
|
|||||||
genre: METAL,
|
genre: METAL,
|
||||||
artistId: METALLICA_ID,
|
artistId: METALLICA_ID,
|
||||||
artistName: METALLICA_NAME,
|
artistName: METALLICA_NAME,
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
image: {
|
image: { system: "subsonic", resource: METALLICA_ID },
|
||||||
small: "http://localhost/METALLICA/sml",
|
|
||||||
medium: "http://localhost/METALLICA/med",
|
|
||||||
large: "http://localhost/METALLICA/lge",
|
|
||||||
},
|
|
||||||
similarArtists: [],
|
similarArtists: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA];
|
export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA];
|
||||||
|
|
||||||
export const ALL_ALBUMS = ALL_ARTISTS.flatMap((it) => it.albums || []);
|
export const ALL_ALBUMS = ALL_ARTISTS.flatMap((it) => it.albums || []);
|
||||||
|
|
||||||
|
|||||||
114
tests/burn.test.ts
Normal file
114
tests/burn.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { assertSystem, BUrn, format, formatForURL, parse } from "../src/burn";
|
||||||
|
|
||||||
|
type BUrnSpec = {
|
||||||
|
burn: BUrn;
|
||||||
|
asString: string;
|
||||||
|
shorthand: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("BUrn", () => {
|
||||||
|
describe("format", () => {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
burn: { system: "internal", resource: "icon:error" },
|
||||||
|
asString: "bnb:internal:icon:error",
|
||||||
|
shorthand: "bnb:i:icon:error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
burn: {
|
||||||
|
system: "external",
|
||||||
|
resource: "http://example.com/widget.jpg",
|
||||||
|
},
|
||||||
|
asString: "bnb:external:http://example.com/widget.jpg",
|
||||||
|
shorthand: "bnb:e:http://example.com/widget.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
burn: { system: "subsonic", resource: "art:1234" },
|
||||||
|
asString: "bnb:subsonic:art:1234",
|
||||||
|
shorthand: "bnb:s:art:1234",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
burn: { system: "navidrome", resource: "art:1234" },
|
||||||
|
asString: "bnb:navidrome:art:1234",
|
||||||
|
shorthand: "bnb:n:art:1234",
|
||||||
|
},
|
||||||
|
] as BUrnSpec[]
|
||||||
|
).forEach(({ burn, asString, shorthand }) => {
|
||||||
|
describe(asString, () => {
|
||||||
|
it("can be formatted as string and then roundtripped back into BUrn", () => {
|
||||||
|
const stringValue = format(burn);
|
||||||
|
expect(stringValue).toEqual(asString);
|
||||||
|
expect(parse(stringValue)).toEqual(burn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be formatted as shorthand string and then roundtripped back into BUrn", () => {
|
||||||
|
const stringValue = format(burn, { shorthand: true });
|
||||||
|
expect(stringValue).toEqual(shorthand);
|
||||||
|
expect(parse(stringValue)).toEqual(burn);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`encrypted ${asString}`, () => {
|
||||||
|
it("can be formatted as an encrypted string and then roundtripped back into BUrn", () => {
|
||||||
|
const stringValue = format(burn, { encrypt: true });
|
||||||
|
expect(stringValue.startsWith("bnb:encrypted:")).toBeTruthy();
|
||||||
|
expect(stringValue).not.toContain(burn.system);
|
||||||
|
expect(stringValue).not.toContain(burn.resource);
|
||||||
|
expect(parse(stringValue)).toEqual(burn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be formatted as an encrypted shorthand string and then roundtripped back into BUrn", () => {
|
||||||
|
const stringValue = format(burn, {
|
||||||
|
shorthand: true,
|
||||||
|
encrypt: true,
|
||||||
|
});
|
||||||
|
expect(stringValue.startsWith("bnb:x:")).toBeTruthy();
|
||||||
|
expect(stringValue).not.toContain(burn.system);
|
||||||
|
expect(stringValue).not.toContain(burn.resource);
|
||||||
|
expect(parse(stringValue)).toEqual(burn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatForURL", () => {
|
||||||
|
describe("external", () => {
|
||||||
|
it("should be encrypted", () => {
|
||||||
|
const burn = {
|
||||||
|
system: "external",
|
||||||
|
resource: "http://example.com/foo.jpg",
|
||||||
|
};
|
||||||
|
const formatted = formatForURL(burn);
|
||||||
|
expect(formatted.startsWith("bnb:x:")).toBeTruthy();
|
||||||
|
expect(formatted).not.toContain("http://example.com/foo.jpg");
|
||||||
|
|
||||||
|
expect(parse(formatted)).toEqual(burn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("not external", () => {
|
||||||
|
it("should be shorthand form", () => {
|
||||||
|
expect(formatForURL({ system: "internal", resource: "foo" })).toEqual(
|
||||||
|
"bnb:i:foo"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
formatForURL({ system: "subsonic", resource: "foo:bar" })
|
||||||
|
).toEqual("bnb:s:foo:bar");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertSystem", () => {
|
||||||
|
it("should fail if the system is not equal", () => {
|
||||||
|
const burn = { system: "external", resource: "something"};
|
||||||
|
expect(() => assertSystem(burn, "subsonic")).toThrow(`Unsupported urn: '${format(burn)}'`)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass if the system is equal", () => {
|
||||||
|
const burn = { system: "external", resource: "something"};
|
||||||
|
expect(assertSystem(burn, "external")).toEqual(burn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "./builders";
|
} from "./builders";
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
|
|
||||||
|
|
||||||
describe("InMemoryMusicService", () => {
|
describe("InMemoryMusicService", () => {
|
||||||
const service = new InMemoryMusicService();
|
const service = new InMemoryMusicService();
|
||||||
|
|
||||||
@@ -52,24 +53,6 @@ describe("InMemoryMusicService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("artistToArtistSummary", () => {
|
|
||||||
it("should map fields correctly", () => {
|
|
||||||
const artist = anArtist({
|
|
||||||
id: uuid(),
|
|
||||||
name: "The Artist",
|
|
||||||
image: {
|
|
||||||
small: "/path/to/small/jpg",
|
|
||||||
medium: "/path/to/medium/jpg",
|
|
||||||
large: "/path/to/large/jpg",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(artistToArtistSummary(artist)).toEqual({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Music Library", () => {
|
describe("Music Library", () => {
|
||||||
const user = { username: "user100", password: "password100" };
|
const user = { username: "user100", password: "password100" };
|
||||||
let musicLibrary: MusicLibrary;
|
let musicLibrary: MusicLibrary;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Genre,
|
Genre,
|
||||||
Rating,
|
Rating,
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
|
import { BUrn } from "../src/burn";
|
||||||
|
|
||||||
export class InMemoryMusicService implements MusicService {
|
export class InMemoryMusicService implements MusicService {
|
||||||
users: Record<string, string> = {};
|
users: Record<string, string> = {};
|
||||||
@@ -131,8 +132,8 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
),
|
),
|
||||||
stream: (_: { trackId: string; range: string | undefined }) =>
|
stream: (_: { trackId: string; range: string | undefined }) =>
|
||||||
Promise.reject("unsupported operation"),
|
Promise.reject("unsupported operation"),
|
||||||
coverArt: (id: string, size?: number) =>
|
coverArt: (coverArtURN: BUrn, size?: number) =>
|
||||||
Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`),
|
Promise.reject(`Cannot retrieve coverArt for ${coverArtURN}, size ${size}`),
|
||||||
scrobble: async (_: string) => {
|
scrobble: async (_: string) => {
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
},
|
},
|
||||||
|
|||||||
22
tests/music_service.test.ts
Normal file
22
tests/music_service.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
|
import { anArtist } from "./builders";
|
||||||
|
import { artistToArtistSummary } from "../src/music_service";
|
||||||
|
|
||||||
|
describe("artistToArtistSummary", () => {
|
||||||
|
it("should map fields correctly", () => {
|
||||||
|
const artist = anArtist({
|
||||||
|
id: uuid(),
|
||||||
|
name: "The Artist",
|
||||||
|
image: {
|
||||||
|
system: "external",
|
||||||
|
resource: "http://example.com:1234/image.jpg",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(artistToArtistSummary(artist)).toEqual({
|
||||||
|
id: artist.id,
|
||||||
|
name: artist.name,
|
||||||
|
image: artist.image,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import randomString from "../src/random_string";
|
|
||||||
|
|
||||||
describe('randomString', () => {
|
|
||||||
it('should produce different strings...', () => {
|
|
||||||
const s1 = randomString()
|
|
||||||
const s2 = randomString()
|
|
||||||
const s3 = randomString()
|
|
||||||
const s4 = randomString()
|
|
||||||
|
|
||||||
expect(s1.length).toEqual(64)
|
|
||||||
|
|
||||||
expect(s1).not.toEqual(s2);
|
|
||||||
expect(s1).not.toEqual(s3);
|
|
||||||
expect(s1).not.toEqual(s4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -24,6 +24,7 @@ import url from "../src/url_builder";
|
|||||||
import i8n, { randomLang } from "../src/i8n";
|
import i8n, { randomLang } from "../src/i8n";
|
||||||
import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi";
|
import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi";
|
||||||
import { Clock, SystemClock } from "../src/clock";
|
import { Clock, SystemClock } from "../src/clock";
|
||||||
|
import { formatForURL } from "../src/burn";
|
||||||
|
|
||||||
describe("rangeFilterFor", () => {
|
describe("rangeFilterFor", () => {
|
||||||
describe("invalid range header string", () => {
|
describe("invalid range header string", () => {
|
||||||
@@ -1190,7 +1191,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("when there is no access-token", () => {
|
describe("when there is no access-token", () => {
|
||||||
it("should return a 401", async () => {
|
it("should return a 401", async () => {
|
||||||
const res = await request(server).get(`/art/coverArt:123/size/180`);
|
const res = await request(server).get(`/art/${encodeURIComponent(formatForURL({ system: "subsonic", resource: "art:whatever" }))}/size/180`);
|
||||||
|
|
||||||
expect(res.status).toEqual(401);
|
expect(res.status).toEqual(401);
|
||||||
});
|
});
|
||||||
@@ -1201,7 +1202,7 @@ describe("server", () => {
|
|||||||
now = now.add(1, "day");
|
now = now.add(1, "day");
|
||||||
|
|
||||||
const res = await request(server).get(
|
const res = await request(server).get(
|
||||||
`/art/coverArt:123/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
`/art/${encodeURIComponent(formatForURL({ system: "subsonic", resource: "art:whatever" }))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toEqual(401);
|
expect(res.status).toEqual(401);
|
||||||
@@ -1209,14 +1210,16 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("when there is a valid access token", () => {
|
describe("when there is a valid access token", () => {
|
||||||
describe("artist art", () => {
|
describe("art", () => {
|
||||||
["0", "-1", "foo"].forEach((size) => {
|
["0", "-1", "foo"].forEach((size) => {
|
||||||
describe(`invalid size of ${size}`, () => {
|
describe(`invalid size of ${size}`, () => {
|
||||||
it(`should return a 400`, async () => {
|
it(`should return a 400`, async () => {
|
||||||
|
const coverArtURN = { system: "subsonic", resource: "art:400" };
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/artist:${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
@@ -1228,6 +1231,8 @@ describe("server", () => {
|
|||||||
describe("fetching a single image", () => {
|
describe("fetching a single image", () => {
|
||||||
describe("when the images is available", () => {
|
describe("when the images is available", () => {
|
||||||
it("should return the image and a 200", async () => {
|
it("should return the image and a 200", async () => {
|
||||||
|
const coverArtURN = { system: "subsonic", resource: "art:200" };
|
||||||
|
|
||||||
const coverArt = coverArtResponse({});
|
const coverArt = coverArtResponse({});
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
@@ -1236,7 +1241,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
@@ -1247,7 +1252,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||||
`artist:${albumId}`,
|
coverArtURN,
|
||||||
180
|
180
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1255,13 +1260,14 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("when the image is not available", () => {
|
describe("when the image is not available", () => {
|
||||||
it("should return a 404", async () => {
|
it("should return a 404", async () => {
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
const coverArtURN = { system: "subsonic", resource: "art:404" };
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.coverArt.mockResolvedValue(undefined);
|
musicLibrary.coverArt.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
@@ -1283,16 +1289,16 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("fetching a collage of 4 when all are available", () => {
|
describe("fetching a collage of 4 when all are available", () => {
|
||||||
it("should return the image and a 200", async () => {
|
it("should return the image and a 200", async () => {
|
||||||
const ids = [
|
const urns = [
|
||||||
"artist:1",
|
"art:1",
|
||||||
"artist:2",
|
"art:2",
|
||||||
"coverArt:3",
|
"art:3",
|
||||||
"coverArt:4",
|
"art:4",
|
||||||
];
|
].map(resource => ({ system:"subsonic", resource }));
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
ids.forEach((_) => {
|
urns.forEach((_) => {
|
||||||
musicLibrary.coverArt.mockResolvedValueOnce(
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
coverArtResponse({
|
coverArtResponse({
|
||||||
data: png,
|
data: png,
|
||||||
@@ -1302,7 +1308,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/${ids.join(
|
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
|
||||||
"&"
|
"&"
|
||||||
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
@@ -1312,8 +1318,8 @@ describe("server", () => {
|
|||||||
expect(res.header["content-type"]).toEqual("image/png");
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
ids.forEach((id) => {
|
urns.forEach((it) => {
|
||||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 200);
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = await Image.load(res.body);
|
const image = await Image.load(res.body);
|
||||||
@@ -1324,7 +1330,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("fetching a collage of 4, however only 1 is available", () => {
|
describe("fetching a collage of 4, however only 1 is available", () => {
|
||||||
it("should return the single image", async () => {
|
it("should return the single image", async () => {
|
||||||
const ids = ["artist:1", "artist:2", "artist:3", "artist:4"];
|
const urns = ["art:1", "art:2", "art:3", "art:4"].map(resource => ({ system:"subsonic", resource }));
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
@@ -1340,7 +1346,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/${ids.join(
|
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
|
||||||
"&"
|
"&"
|
||||||
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
@@ -1355,17 +1361,17 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("fetching a collage of 4 and all are missing", () => {
|
describe("fetching a collage of 4 and all are missing", () => {
|
||||||
it("should return a 404", async () => {
|
it("should return a 404", async () => {
|
||||||
const ids = ["artist:1", "artist:2", "artist:3", "artist:4"];
|
const urns = ["art:1", "art:2", "art:3", "art:4"].map(resource => ({ system:"subsonic", resource }));
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
ids.forEach((_) => {
|
urns.forEach((_) => {
|
||||||
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/${ids.join(
|
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
|
||||||
"&"
|
"&"
|
||||||
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
@@ -1377,7 +1383,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("fetching a collage of 9 when all are available", () => {
|
describe("fetching a collage of 9 when all are available", () => {
|
||||||
it("should return the image and a 200", async () => {
|
it("should return the image and a 200", async () => {
|
||||||
const ids = [
|
const urns = [
|
||||||
"artist:1",
|
"artist:1",
|
||||||
"artist:2",
|
"artist:2",
|
||||||
"coverArt:3",
|
"coverArt:3",
|
||||||
@@ -1387,11 +1393,11 @@ describe("server", () => {
|
|||||||
"artist:7",
|
"artist:7",
|
||||||
"artist:8",
|
"artist:8",
|
||||||
"artist:9",
|
"artist:9",
|
||||||
];
|
].map(resource => ({ system:"subsonic", resource }));
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
ids.forEach((_) => {
|
urns.forEach((_) => {
|
||||||
musicLibrary.coverArt.mockResolvedValueOnce(
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
coverArtResponse({
|
coverArtResponse({
|
||||||
data: png,
|
data: png,
|
||||||
@@ -1401,7 +1407,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/${ids.join(
|
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
|
||||||
"&"
|
"&"
|
||||||
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
@@ -1411,8 +1417,8 @@ describe("server", () => {
|
|||||||
expect(res.header["content-type"]).toEqual("image/png");
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
ids.forEach((id) => {
|
urns.forEach((it) => {
|
||||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180);
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180);
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = await Image.load(res.body);
|
const image = await Image.load(res.body);
|
||||||
@@ -1423,7 +1429,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("fetching a collage of 9 when only 2 are available", () => {
|
describe("fetching a collage of 9 when only 2 are available", () => {
|
||||||
it("should still return an image and a 200", async () => {
|
it("should still return an image and a 200", async () => {
|
||||||
const ids = [
|
const urns = [
|
||||||
"artist:1",
|
"artist:1",
|
||||||
"artist:2",
|
"artist:2",
|
||||||
"artist:3",
|
"artist:3",
|
||||||
@@ -1433,7 +1439,7 @@ describe("server", () => {
|
|||||||
"artist:7",
|
"artist:7",
|
||||||
"artist:8",
|
"artist:8",
|
||||||
"artist:9",
|
"artist:9",
|
||||||
];
|
].map(resource => ({ system:"subsonic", resource }));
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
@@ -1457,7 +1463,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/${ids.join(
|
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
|
||||||
"&"
|
"&"
|
||||||
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
@@ -1467,8 +1473,8 @@ describe("server", () => {
|
|||||||
expect(res.header["content-type"]).toEqual("image/png");
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
ids.forEach((id) => {
|
urns.forEach((urn) => {
|
||||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180);
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(urn, 180);
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = await Image.load(res.body);
|
const image = await Image.load(res.body);
|
||||||
@@ -1479,7 +1485,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("fetching a collage of 11", () => {
|
describe("fetching a collage of 11", () => {
|
||||||
it("should still return an image and a 200, though will only display 9", async () => {
|
it("should still return an image and a 200, though will only display 9", async () => {
|
||||||
const ids = [
|
const urns = [
|
||||||
"artist:1",
|
"artist:1",
|
||||||
"artist:2",
|
"artist:2",
|
||||||
"artist:3",
|
"artist:3",
|
||||||
@@ -1491,11 +1497,11 @@ describe("server", () => {
|
|||||||
"artist:9",
|
"artist:9",
|
||||||
"artist:10",
|
"artist:10",
|
||||||
"artist:11",
|
"artist:11",
|
||||||
];
|
].map(resource => ({ system:"subsonic", resource }));
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
ids.forEach((_) => {
|
urns.forEach((_) => {
|
||||||
musicLibrary.coverArt.mockResolvedValueOnce(
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
coverArtResponse({
|
coverArtResponse({
|
||||||
data: png,
|
data: png,
|
||||||
@@ -1505,7 +1511,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/${ids.join(
|
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
|
||||||
"&"
|
"&"
|
||||||
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
@@ -1515,8 +1521,8 @@ describe("server", () => {
|
|||||||
expect(res.header["content-type"]).toEqual("image/png");
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
ids.forEach((id) => {
|
urns.forEach((it) => {
|
||||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180);
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180);
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = await Image.load(res.body);
|
const image = await Image.load(res.body);
|
||||||
@@ -1527,13 +1533,14 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("when the image is not available", () => {
|
describe("when the image is not available", () => {
|
||||||
it("should return a 404", async () => {
|
it("should return a 404", async () => {
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
const coverArtURN = { system:"subsonic", resource:"art:404"};
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
musicLibrary.coverArt.mockResolvedValue(undefined);
|
musicLibrary.coverArt.mockResolvedValue(undefined);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/coverArt:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
@@ -1558,83 +1565,6 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("album art", () => {
|
|
||||||
["0", "-1", "foo"].forEach((size) => {
|
|
||||||
describe(`when the size is ${size}`, () => {
|
|
||||||
it(`should return a 400`, async () => {
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
|
||||||
const res = await request(server)
|
|
||||||
.get(
|
|
||||||
`/art/coverArt:${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
|
||||||
)
|
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
|
||||||
|
|
||||||
expect(res.status).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when there is some", () => {
|
|
||||||
it("should return the image and a 200", async () => {
|
|
||||||
const coverArt = {
|
|
||||||
status: 200,
|
|
||||||
contentType: "image/jpeg",
|
|
||||||
data: Buffer.from("some image", "ascii"),
|
|
||||||
};
|
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
|
||||||
musicLibrary.coverArt.mockResolvedValue(coverArt);
|
|
||||||
|
|
||||||
const res = await request(server)
|
|
||||||
.get(
|
|
||||||
`/art/coverArt:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
|
||||||
)
|
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
|
||||||
|
|
||||||
expect(res.status).toEqual(coverArt.status);
|
|
||||||
expect(res.header["content-type"]).toEqual(
|
|
||||||
coverArt.contentType
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
|
||||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
|
||||||
`coverArt:${albumId}`,
|
|
||||||
180
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when there isnt any", () => {
|
|
||||||
it("should return a 404", async () => {
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
|
||||||
musicLibrary.coverArt.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const res = await request(server)
|
|
||||||
.get(
|
|
||||||
`/art/album:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
|
||||||
)
|
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
|
||||||
|
|
||||||
expect(res.status).toEqual(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when there is an error", () => {
|
|
||||||
it("should return a 500", async () => {
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
|
||||||
musicLibrary.coverArt.mockRejectedValue("Boooooom");
|
|
||||||
|
|
||||||
const res = await request(server)
|
|
||||||
.get(
|
|
||||||
`/art/album:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
|
||||||
)
|
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
|
||||||
|
|
||||||
expect(res.status).toEqual(500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
TRIP_HOP,
|
TRIP_HOP,
|
||||||
PUNK,
|
PUNK,
|
||||||
aPlaylist,
|
aPlaylist,
|
||||||
|
anAlbumSummary,
|
||||||
} from "./builders";
|
} from "./builders";
|
||||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||||
import supersoap from "./supersoap";
|
import supersoap from "./supersoap";
|
||||||
@@ -56,6 +57,8 @@ import dayjs from "dayjs";
|
|||||||
import url, { URLBuilder } from "../src/url_builder";
|
import url, { URLBuilder } from "../src/url_builder";
|
||||||
import { iconForGenre } from "../src/icon";
|
import { iconForGenre } from "../src/icon";
|
||||||
import { jwtSigner } from "../src/encryption";
|
import { jwtSigner } from "../src/encryption";
|
||||||
|
import { formatForURL } from "../src/burn";
|
||||||
|
import { range } from "underscore";
|
||||||
|
|
||||||
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
|
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
|
||||||
|
|
||||||
@@ -359,7 +362,7 @@ describe("track", () => {
|
|||||||
genre: { id: "genre101", name: "some genre" },
|
genre: { id: "genre101", name: "some genre" },
|
||||||
}),
|
}),
|
||||||
artist: anArtist({ name: "great artist", id: uuid() }),
|
artist: anArtist({ name: "great artist", id: uuid() }),
|
||||||
coverArt: "coverArt:887766",
|
coverArt: {system: "subsonic", resource: "887766"},
|
||||||
rating: {
|
rating: {
|
||||||
love: true,
|
love: true,
|
||||||
stars: 5
|
stars: 5
|
||||||
@@ -377,7 +380,7 @@ describe("track", () => {
|
|||||||
albumId: `album:${someTrack.album.id}`,
|
albumId: `album:${someTrack.album.id}`,
|
||||||
albumArtist: someTrack.artist.name,
|
albumArtist: someTrack.artist.name,
|
||||||
albumArtistId: `artist:${someTrack.artist.id}`,
|
albumArtistId: `artist:${someTrack.artist.id}`,
|
||||||
albumArtURI: `http://localhost:4567/foo/art/${someTrack.coverArt}/size/180?access-token=1234`,
|
albumArtURI: `http://localhost:4567/foo/art/${encodeURIComponent(formatForURL(someTrack.coverArt!))}/size/180?access-token=1234`,
|
||||||
artist: someTrack.artist.name,
|
artist: someTrack.artist.name,
|
||||||
artistId: `artist:${someTrack.artist.id}`,
|
artistId: `artist:${someTrack.artist.id}`,
|
||||||
duration: someTrack.duration,
|
duration: someTrack.duration,
|
||||||
@@ -431,6 +434,12 @@ describe("sonosifyMimeType", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("playlistAlbumArtURL", () => {
|
describe("playlistAlbumArtURL", () => {
|
||||||
|
const coverArt1 = { system: "subsonic", resource: "1" };
|
||||||
|
const coverArt2 = { system: "subsonic", resource: "2" };
|
||||||
|
const coverArt3 = { system: "subsonic", resource: "3" };
|
||||||
|
const coverArt4 = { system: "subsonic", resource: "4" };
|
||||||
|
const coverArt5 = { system: "subsonic", resource: "5" };
|
||||||
|
|
||||||
describe("when the playlist has no coverArt ids", () => {
|
describe("when the playlist has no coverArt ids", () => {
|
||||||
it("should return question mark icon", () => {
|
it("should return question mark icon", () => {
|
||||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
@@ -447,39 +456,97 @@ describe("playlistAlbumArtURL", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when the playlist has 2 distinct coverArt ids", () => {
|
describe("when the playlist has external ids", () => {
|
||||||
it("should return them on the url to the image", () => {
|
it("should format the url with encrypted urn", () => {
|
||||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
|
const externalArt1 = { system: "external", resource: "http://example.com/image1.jpg" };
|
||||||
|
const externalArt2 = { system: "external", resource: "http://example.com/image2.jpg" };
|
||||||
|
|
||||||
const playlist = aPlaylist({
|
const playlist = aPlaylist({
|
||||||
entries: [
|
entries: [
|
||||||
aTrack({ coverArt: "1" }),
|
aTrack({ coverArt: externalArt1, album: anAlbumSummary({id: "album1"}) }),
|
||||||
aTrack({ coverArt: "2" }),
|
aTrack({ coverArt: externalArt2, album: anAlbumSummary({id: "album2"}) }),
|
||||||
aTrack({ coverArt: "1" }),
|
|
||||||
aTrack({ coverArt: "2" }),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
`http://localhost:1234/context-path/art/1&2/size/180?search=yes`
|
`http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(externalArt1))}&${encodeURIComponent(formatForURL(externalArt2))}/size/180?search=yes`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when the playlist has 4 distinct albumIds", () => {
|
describe("when the playlist has 4 tracks from 2 different albums, including some tracks that are missing coverArt urns", () => {
|
||||||
it("should return them on the url to the image", () => {
|
it("should use the cover art once per album", () => {
|
||||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
const playlist = aPlaylist({
|
const playlist = aPlaylist({
|
||||||
entries: [
|
entries: [
|
||||||
aTrack({ coverArt: "1" }),
|
aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album1" }) }),
|
||||||
aTrack({ coverArt: "2" }),
|
aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }),
|
||||||
aTrack({ coverArt: "2" }),
|
aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }),
|
||||||
aTrack({ coverArt: "3" }),
|
aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album2" }) }),
|
||||||
aTrack({ coverArt: "4" }),
|
aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }),
|
||||||
|
aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album2" }) }),
|
||||||
|
aTrack({ coverArt: undefined, album: anAlbumSummary({id: "album2" }) }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
`http://localhost:1234/context-path/art/1&2&3&4/size/180?search=yes`
|
`http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the playlist has 4 tracks from 2 different albums", () => {
|
||||||
|
it("should use the cover art once per album", () => {
|
||||||
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
|
const playlist = aPlaylist({
|
||||||
|
entries: [
|
||||||
|
aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }),
|
||||||
|
aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }),
|
||||||
|
aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }),
|
||||||
|
aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album2" }) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the playlist has 4 tracks from 3 different albums", () => {
|
||||||
|
it("should use the cover art once per album", () => {
|
||||||
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
|
const playlist = aPlaylist({
|
||||||
|
entries: [
|
||||||
|
aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1" }) }),
|
||||||
|
aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2" }) }),
|
||||||
|
aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album1" }) }),
|
||||||
|
aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album3" }) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the playlist has 4 tracks from 4 different albums", () => {
|
||||||
|
it("should return them on the url to the image", () => {
|
||||||
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
|
const playlist = aPlaylist({
|
||||||
|
entries: [
|
||||||
|
aTrack({ coverArt: coverArt1, album: anAlbumSummary({id: "album1"} ) }),
|
||||||
|
aTrack({ coverArt: coverArt2, album: anAlbumSummary({id: "album2"} ) }),
|
||||||
|
aTrack({ coverArt: coverArt3, album: anAlbumSummary({id: "album3"} ) }),
|
||||||
|
aTrack({ coverArt: coverArt4, album: anAlbumSummary({id: "album4"} ) }),
|
||||||
|
aTrack({ coverArt: coverArt5, album: anAlbumSummary({id: "album1"} ) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/art/${encodeURIComponent(formatForURL(coverArt1))}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(formatForURL(coverArt3))}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -489,24 +556,23 @@ describe("playlistAlbumArtURL", () => {
|
|||||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
const playlist = aPlaylist({
|
const playlist = aPlaylist({
|
||||||
entries: [
|
entries: [
|
||||||
aTrack({ coverArt: "1" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "1" }, album: anAlbumSummary({ id:"1" }) }),
|
||||||
aTrack({ coverArt: "2" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "2" }, album: anAlbumSummary({ id:"2" }) }),
|
||||||
aTrack({ coverArt: "2" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "3" }, album: anAlbumSummary({ id:"3" }) }),
|
||||||
aTrack({ coverArt: "2" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "4" }, album: anAlbumSummary({ id:"4" }) }),
|
||||||
aTrack({ coverArt: "3" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "5" }, album: anAlbumSummary({ id:"5" }) }),
|
||||||
aTrack({ coverArt: "4" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "6" }, album: anAlbumSummary({ id:"6" }) }),
|
||||||
aTrack({ coverArt: "5" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "7" }, album: anAlbumSummary({ id:"7" }) }),
|
||||||
aTrack({ coverArt: "6" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "8" }, album: anAlbumSummary({ id:"8" }) }),
|
||||||
aTrack({ coverArt: "7" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "9" }, album: anAlbumSummary({ id:"9" }) }),
|
||||||
aTrack({ coverArt: "8" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "10" }, album: anAlbumSummary({ id:"10" }) }),
|
||||||
aTrack({ coverArt: "9" }),
|
aTrack({ coverArt: { system: "subsonic", resource: "11" }, album: anAlbumSummary({ id:"11" }) }),
|
||||||
aTrack({ coverArt: "10" }),
|
|
||||||
aTrack({ coverArt: "11" }),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const burns = range(1, 10).map(i => encodeURIComponent(formatForURL({ system: "subsonic", resource: `${i}` }))).join("&")
|
||||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
`http://localhost:1234/context-path/art/1&2&3&4&5&6&7&8&9/size/180?search=yes`
|
`http://localhost:1234/context-path/art/${burns}/size/180?search=yes`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -518,15 +584,32 @@ describe("defaultAlbumArtURI", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("when there is an album coverArt", () => {
|
describe("when there is an album coverArt", () => {
|
||||||
it("should use it in the image url", () => {
|
describe("from subsonic", () => {
|
||||||
expect(
|
it("should use it", () => {
|
||||||
defaultAlbumArtURI(
|
const coverArt = { system: "subsonic", resource: "12345" }
|
||||||
bonobUrl,
|
expect(
|
||||||
anAlbum({ coverArt: "coverArt:123" })
|
defaultAlbumArtURI(
|
||||||
).href()
|
bonobUrl,
|
||||||
).toEqual(
|
anAlbum({ coverArt })
|
||||||
"http://bonob.example.com:8080/context/art/coverArt:123/size/180?search=yes"
|
).href()
|
||||||
);
|
).toEqual(
|
||||||
|
`http://bonob.example.com:8080/context/art/${encodeURIComponent(formatForURL(coverArt))}/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("that is external", () => {
|
||||||
|
it("should use encrypt it", () => {
|
||||||
|
const coverArt = { system: "external", resource: "http://example.com/someimage.jpg" }
|
||||||
|
expect(
|
||||||
|
defaultAlbumArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
anAlbum({ coverArt })
|
||||||
|
).href()
|
||||||
|
).toEqual(
|
||||||
|
`http://bonob.example.com:8080/context/art/${encodeURIComponent(formatForURL(coverArt))}/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -542,13 +625,39 @@ describe("defaultAlbumArtURI", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("defaultArtistArtURI", () => {
|
describe("defaultArtistArtURI", () => {
|
||||||
it("should create the correct URI", () => {
|
describe("when the artist has no image", () => {
|
||||||
const bonobUrl = url("http://localhost:1234/something?s=123");
|
it("should return an icon", () => {
|
||||||
const artist = anArtist();
|
const bonobUrl = url("http://localhost:1234/something?s=123");
|
||||||
|
const artist = anArtist({ image: undefined });
|
||||||
|
|
||||||
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
|
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
|
||||||
`http://localhost:1234/something/art/artist:${artist.id}/size/180?s=123`
|
`http://localhost:1234/something/icon/vinyl/size/legacy?s=123`
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the resource is subsonic", () => {
|
||||||
|
it("should use the resource", () => {
|
||||||
|
const bonobUrl = url("http://localhost:1234/something?s=123");
|
||||||
|
const image = { system:"subsonic", resource: "art:1234"};
|
||||||
|
const artist = anArtist({ image });
|
||||||
|
|
||||||
|
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
|
||||||
|
`http://localhost:1234/something/art/${encodeURIComponent(formatForURL(image))}/size/180?s=123`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the resource is external", () => {
|
||||||
|
it("should encrypt the resource", () => {
|
||||||
|
const bonobUrl = url("http://localhost:1234/something?s=123");
|
||||||
|
const image = { system:"external", resource: "http://example.com/something.jpg"};
|
||||||
|
const artist = anArtist({ image });
|
||||||
|
|
||||||
|
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
|
||||||
|
`http://localhost:1234/something/art/${encodeURIComponent(formatForURL(image))}/size/180?s=123`
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
43
yarn.lock
43
yarn.lock
@@ -1269,6 +1269,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/randomstring@npm:^1.1.8":
|
||||||
|
version: 1.1.8
|
||||||
|
resolution: "@types/randomstring@npm:1.1.8"
|
||||||
|
checksum: 22a9e4b09583ad8e7fa7ca214133abc014636d7d6eb49ca9ee671c09b241311107b0a6ea48205bf795ac61fbe5b185ac415aed2dd27c7f5806235bfea0e5532f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/range-parser@npm:*":
|
"@types/range-parser@npm:*":
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
resolution: "@types/range-parser@npm:1.2.3"
|
resolution: "@types/range-parser@npm:1.2.3"
|
||||||
@@ -1600,6 +1607,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"array-uniq@npm:1.0.2":
|
||||||
|
version: 1.0.2
|
||||||
|
resolution: "array-uniq@npm:1.0.2"
|
||||||
|
checksum: 8c4beb94aa183791da1e155935aba4df3fe2eeb6f491c69e666ca7351f897b9b260fa04d016e0ce766ae8280129c16f11071e17359c81c01741289009bb5ac6d
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"assertion-error@npm:^1.1.0":
|
"assertion-error@npm:^1.1.0":
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
resolution: "assertion-error@npm:1.1.0"
|
resolution: "assertion-error@npm:1.1.0"
|
||||||
@@ -1805,6 +1819,7 @@ __metadata:
|
|||||||
"@types/mocha": ^9.0.0
|
"@types/mocha": ^9.0.0
|
||||||
"@types/morgan": ^1.9.3
|
"@types/morgan": ^1.9.3
|
||||||
"@types/node": ^16.7.13
|
"@types/node": ^16.7.13
|
||||||
|
"@types/randomstring": ^1.1.8
|
||||||
"@types/sharp": ^0.28.6
|
"@types/sharp": ^0.28.6
|
||||||
"@types/supertest": ^2.0.11
|
"@types/supertest": ^2.0.11
|
||||||
"@types/tmp": ^0.2.1
|
"@types/tmp": ^0.2.1
|
||||||
@@ -1826,6 +1841,7 @@ __metadata:
|
|||||||
morgan: ^1.10.0
|
morgan: ^1.10.0
|
||||||
node-html-parser: ^4.1.4
|
node-html-parser: ^4.1.4
|
||||||
nodemon: ^2.0.12
|
nodemon: ^2.0.12
|
||||||
|
randomstring: ^1.2.1
|
||||||
sharp: ^0.29.1
|
sharp: ^0.29.1
|
||||||
soap: ^0.42.0
|
soap: ^0.42.0
|
||||||
supertest: ^6.1.6
|
supertest: ^6.1.6
|
||||||
@@ -1836,6 +1852,7 @@ __metadata:
|
|||||||
ts-node: ^10.2.1
|
ts-node: ^10.2.1
|
||||||
typescript: ^4.4.2
|
typescript: ^4.4.2
|
||||||
underscore: ^1.13.1
|
underscore: ^1.13.1
|
||||||
|
urn-lib: ^2.0.0
|
||||||
uuid: ^8.3.2
|
uuid: ^8.3.2
|
||||||
winston: ^3.3.3
|
winston: ^3.3.3
|
||||||
xmldom-ts: ^0.3.1
|
xmldom-ts: ^0.3.1
|
||||||
@@ -6013,6 +6030,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"randombytes@npm:2.0.3":
|
||||||
|
version: 2.0.3
|
||||||
|
resolution: "randombytes@npm:2.0.3"
|
||||||
|
checksum: 13e1abd143404dd87024bf345fb1a446b2e2ee46d8e1a5a073e8370c9b1e58000d81a97d4327ba7089087213eb6d8c77fa67ab4e91aa00605126d634fcccb9d4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"randomstring@npm:^1.2.1":
|
||||||
|
version: 1.2.1
|
||||||
|
resolution: "randomstring@npm:1.2.1"
|
||||||
|
dependencies:
|
||||||
|
array-uniq: 1.0.2
|
||||||
|
randombytes: 2.0.3
|
||||||
|
bin:
|
||||||
|
randomstring: bin/randomstring
|
||||||
|
checksum: 501da2ec59638d502dbb66c237ab80790dbb0b50b493347cbf6abc2dfbf6fe08f195d85e37911689bc406f85d31cf9826757398b5af8c9cae7c0ad4f808f3ac0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"range-parser@npm:~1.2.1":
|
"range-parser@npm:~1.2.1":
|
||||||
version: 1.2.1
|
version: 1.2.1
|
||||||
resolution: "range-parser@npm:1.2.1"
|
resolution: "range-parser@npm:1.2.1"
|
||||||
@@ -7235,6 +7271,13 @@ typescript@^4.4.2:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"urn-lib@npm:^2.0.0":
|
||||||
|
version: 2.0.0
|
||||||
|
resolution: "urn-lib@npm:2.0.0"
|
||||||
|
checksum: fde3f4b8c38483d6229fe49e23cbf9cc012e0b4459d6aacb9bb2f3f1a32992b0e1115122401cca3708598ffa279355182158c7f7c248881ff72dc0f9e9f76d82
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"utf8@npm:^2.1.2":
|
"utf8@npm:^2.1.2":
|
||||||
version: 2.1.2
|
version: 2.1.2
|
||||||
resolution: "utf8@npm:2.1.2"
|
resolution: "utf8@npm:2.1.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user