mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Rendering playlist icon collage of 3x3 (#35)
This commit is contained in:
@@ -2,4 +2,8 @@ module.exports = {
|
|||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
|
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
|
||||||
|
modulePathIgnorePatterns: [
|
||||||
|
'<rootDir>/node_modules',
|
||||||
|
'<rootDir>/build',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
@@ -53,6 +53,6 @@
|
|||||||
"dev": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
|
"dev": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
|
||||||
"devr": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
|
"devr": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
|
||||||
"register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
|
"register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
|
||||||
"test": "jest --testPathIgnorePatterns=build"
|
"test": "jest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/app.ts
13
src/app.ts
@@ -61,11 +61,14 @@ const app = server(
|
|||||||
bonob,
|
bonob,
|
||||||
config.bonobUrl,
|
config.bonobUrl,
|
||||||
featureFlagAwareMusicService,
|
featureFlagAwareMusicService,
|
||||||
new InMemoryLinkCodes(),
|
{
|
||||||
new InMemoryAccessTokens(sha256(config.secret)),
|
linkCodes: () => new InMemoryLinkCodes(),
|
||||||
SystemClock,
|
accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)),
|
||||||
config.icons,
|
clock: SystemClock,
|
||||||
true,
|
iconColors: config.icons,
|
||||||
|
applyContextPath: true,
|
||||||
|
logRequests: true
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
|
|||||||
@@ -267,7 +267,8 @@ export type ICON =
|
|||||||
| "trumpet"
|
| "trumpet"
|
||||||
| "conductor"
|
| "conductor"
|
||||||
| "reggae"
|
| "reggae"
|
||||||
| "music";
|
| "music"
|
||||||
|
| "error";
|
||||||
|
|
||||||
const iconFrom = (name: string) =>
|
const iconFrom = (name: string) =>
|
||||||
new SvgIcon(
|
new SvgIcon(
|
||||||
@@ -310,7 +311,8 @@ export const ICONS: Record<ICON, Icon> = {
|
|||||||
trumpet: iconFrom("Trumpet-17823.svg"),
|
trumpet: iconFrom("Trumpet-17823.svg"),
|
||||||
conductor: iconFrom("Music-Conductor-225.svg"),
|
conductor: iconFrom("Music-Conductor-225.svg"),
|
||||||
reggae: iconFrom("Reggae-24843.svg"),
|
reggae: iconFrom("Reggae-24843.svg"),
|
||||||
music: iconFrom("Music-14097.svg")
|
music: iconFrom("Music-14097.svg"),
|
||||||
|
error: iconFrom("Error-82783.svg"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RULE = (genre: string) => boolean;
|
export type RULE = (genre: string) => boolean;
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
|||||||
artistId: it.artistId,
|
artistId: it.artistId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name
|
||||||
|
})
|
||||||
|
|
||||||
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
|
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
|
||||||
|
|
||||||
export type TrackStream = {
|
export type TrackStream = {
|
||||||
|
|||||||
143
src/server.ts
143
src/server.ts
@@ -1,7 +1,7 @@
|
|||||||
import { option as O } from "fp-ts";
|
import { option as O } from "fp-ts";
|
||||||
import express, { Express, Request } from "express";
|
import express, { Express, Request } from "express";
|
||||||
import * as Eta from "eta";
|
import * as Eta from "eta";
|
||||||
import morgan from "morgan";
|
// import morgan from "morgan";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ import { pipe } from "fp-ts/lib/function";
|
|||||||
import { URLBuilder } from "./url_builder";
|
import { URLBuilder } from "./url_builder";
|
||||||
import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
|
import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
|
||||||
import { Icon, makeFestive, ICONS } from "./icon";
|
import { Icon, makeFestive, ICONS } from "./icon";
|
||||||
|
import _, { shuffle } from "underscore";
|
||||||
|
import morgan from "morgan";
|
||||||
|
import { takeWithRepeats } from "./utils";
|
||||||
|
|
||||||
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||||
|
|
||||||
@@ -67,24 +70,46 @@ export class RangeBytesFromFilter extends Transform {
|
|||||||
range = (number: number) => `${this.from}-${number - 1}/${number}`;
|
range = (number: number) => `${this.from}-${number - 1}/${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServerOpts = {
|
||||||
|
linkCodes: () => LinkCodes;
|
||||||
|
accessTokens: () => AccessTokens;
|
||||||
|
clock: Clock;
|
||||||
|
iconColors: {
|
||||||
|
foregroundColor: string | undefined;
|
||||||
|
backgroundColor: string | undefined;
|
||||||
|
};
|
||||||
|
applyContextPath: boolean;
|
||||||
|
logRequests: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||||
|
linkCodes: () => new InMemoryLinkCodes(),
|
||||||
|
accessTokens: () => new AccessTokenPerAuthToken(),
|
||||||
|
clock: SystemClock,
|
||||||
|
iconColors: { foregroundColor: undefined, backgroundColor: undefined },
|
||||||
|
applyContextPath: true,
|
||||||
|
logRequests: false,
|
||||||
|
};
|
||||||
|
|
||||||
function server(
|
function server(
|
||||||
sonos: Sonos,
|
sonos: Sonos,
|
||||||
service: Service,
|
service: Service,
|
||||||
bonobUrl: URLBuilder,
|
bonobUrl: URLBuilder,
|
||||||
musicService: MusicService,
|
musicService: MusicService,
|
||||||
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
opts: Partial<ServerOpts> = {}
|
||||||
accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
|
|
||||||
clock: Clock = SystemClock,
|
|
||||||
iconColors: {
|
|
||||||
foregroundColor: string | undefined;
|
|
||||||
backgroundColor: string | undefined;
|
|
||||||
} = { foregroundColor: undefined, backgroundColor: undefined },
|
|
||||||
applyContextPath = true
|
|
||||||
): Express {
|
): Express {
|
||||||
|
const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts };
|
||||||
|
|
||||||
|
const linkCodes = serverOpts.linkCodes();
|
||||||
|
const accessTokens = serverOpts.accessTokens();
|
||||||
|
const clock = serverOpts.clock;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const i8n = makeI8N(service.name);
|
const i8n = makeI8N(service.name);
|
||||||
|
|
||||||
app.use(morgan("combined"));
|
if (serverOpts.logRequests) {
|
||||||
|
app.use(morgan("combined"));
|
||||||
|
}
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
// todo: pass options in here?
|
// todo: pass options in here?
|
||||||
@@ -94,6 +119,8 @@ function server(
|
|||||||
app.set("view engine", "eta");
|
app.set("view engine", "eta");
|
||||||
app.set("views", path.resolve(__dirname, "..", "web", "views"));
|
app.set("views", path.resolve(__dirname, "..", "web", "views"));
|
||||||
|
|
||||||
|
app.set("query parser", "simple");
|
||||||
|
|
||||||
const langFor = (req: Request) => {
|
const langFor = (req: Request) => {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`${req.path} (req[accept-language]=${req.headers["accept-language"]})`
|
`${req.path} (req[accept-language]=${req.headers["accept-language"]})`
|
||||||
@@ -387,46 +414,92 @@ function server(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
makeFestive(icon.with({ text, ...iconColors }), clock).toString()
|
makeFestive(
|
||||||
|
icon.with({ text, ...serverOpts.iconColors }),
|
||||||
|
clock
|
||||||
|
).toString()
|
||||||
)
|
)
|
||||||
.then(spec.responseFormatter)
|
.then(spec.responseFormatter)
|
||||||
.then((data) => res.status(200).type(spec.mimeType).send(data));
|
.then((data) => res.status(200).type(spec.mimeType).send(data));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/art/:type/:id/size/:size", (req, res) => {
|
const GRAVITY_9 = [
|
||||||
|
"north",
|
||||||
|
"northeast",
|
||||||
|
"east",
|
||||||
|
"southeast",
|
||||||
|
"south",
|
||||||
|
"southwest",
|
||||||
|
"west",
|
||||||
|
"northwest",
|
||||||
|
"centre",
|
||||||
|
];
|
||||||
|
|
||||||
|
app.get("/art/:type/:ids/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 type = req.params["type"]!;
|
const type = req.params["type"]!;
|
||||||
const id = req.params["id"]!;
|
const ids = req.params["ids"]!.split("&");
|
||||||
const size = req.params["size"]!;
|
const size = Number.parseInt(req.params["size"]!);
|
||||||
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return res.status(401).send();
|
return res.status(401).send();
|
||||||
} else if (type != "artist" && type != "album") {
|
} else if (type != "artist" && type != "album") {
|
||||||
return res.status(400).send();
|
return res.status(400).send();
|
||||||
} else if (!(size.match(/^\d+$/) && Number.parseInt(size) > 0)) {
|
} else if (!(size > 0)) {
|
||||||
return res.status(400).send();
|
return res.status(400).send();
|
||||||
} else {
|
|
||||||
return musicService
|
|
||||||
.login(authToken)
|
|
||||||
.then((it) => it.coverArt(id, type, Number.parseInt(size)))
|
|
||||||
.then((coverArt) => {
|
|
||||||
if (coverArt) {
|
|
||||||
res.status(200);
|
|
||||||
res.setHeader("content-type", coverArt.contentType);
|
|
||||||
return res.send(coverArt.data);
|
|
||||||
} else {
|
|
||||||
return res.status(404).send();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e: Error) => {
|
|
||||||
logger.error(`Failed fetching image ${type}/${id}/size/${size}`, {
|
|
||||||
cause: e,
|
|
||||||
});
|
|
||||||
return res.status(500).send();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return musicService
|
||||||
|
.login(authToken)
|
||||||
|
.then((it) => Promise.all(ids.map((id) => it.coverArt(id, type, size))))
|
||||||
|
.then((coverArts) => coverArts.filter((it) => it))
|
||||||
|
.then(shuffle)
|
||||||
|
.then((coverArts) => {
|
||||||
|
if (coverArts.length == 1) {
|
||||||
|
const coverArt = coverArts[0]!;
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("content-type", coverArt.contentType);
|
||||||
|
return res.send(coverArt.data);
|
||||||
|
} else if (coverArts.length > 1) {
|
||||||
|
const gravity = [...GRAVITY_9];
|
||||||
|
return sharp({
|
||||||
|
create: {
|
||||||
|
width: size * 3,
|
||||||
|
height: size * 3,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 255, g: 255, b: 255 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite(
|
||||||
|
takeWithRepeats(coverArts, 9).map((art) => ({
|
||||||
|
input: art?.data,
|
||||||
|
gravity: gravity.pop(),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.png()
|
||||||
|
.toBuffer()
|
||||||
|
.then((image) => sharp(image).resize(size).png().toBuffer())
|
||||||
|
.then((image) => {
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("content-type", "image/png");
|
||||||
|
return res.send(image);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed fetching image ${type}/${ids.join("&")}/size/${size}`,
|
||||||
|
{
|
||||||
|
cause: e,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.status(500).send();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
bindSmapiSoapServiceToExpress(
|
bindSmapiSoapServiceToExpress(
|
||||||
@@ -440,7 +513,7 @@ function server(
|
|||||||
i8n
|
i8n
|
||||||
);
|
);
|
||||||
|
|
||||||
if (applyContextPath) {
|
if (serverOpts.applyContextPath) {
|
||||||
const container = express();
|
const container = express();
|
||||||
container.use(bonobUrl.path(), app);
|
container.use(bonobUrl.path(), app);
|
||||||
return container;
|
return container;
|
||||||
|
|||||||
133
src/smapi.ts
133
src/smapi.ts
@@ -5,7 +5,6 @@ import { readFileSync } from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
|
||||||
|
|
||||||
import { LinkCodes } from "./link_codes";
|
import { LinkCodes } from "./link_codes";
|
||||||
import {
|
import {
|
||||||
Album,
|
Album,
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
ArtistSummary,
|
ArtistSummary,
|
||||||
Genre,
|
Genre,
|
||||||
MusicService,
|
MusicService,
|
||||||
PlaylistSummary,
|
Playlist,
|
||||||
slice2,
|
slice2,
|
||||||
Track,
|
Track,
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
@@ -24,6 +23,7 @@ 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";
|
||||||
|
|
||||||
export const LOGIN_ROUTE = "/login";
|
export const LOGIN_ROUTE = "/login";
|
||||||
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||||
@@ -184,11 +184,14 @@ class SonosSoap {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
logger.info("Client not linked, awaiting user to associate account with link code by logging in.")
|
logger.info(
|
||||||
|
"Client not linked, awaiting user to associate account with link code by logging in."
|
||||||
|
);
|
||||||
throw {
|
throw {
|
||||||
Fault: {
|
Fault: {
|
||||||
faultcode: "Client.NOT_LINKED_RETRY",
|
faultcode: "Client.NOT_LINKED_RETRY",
|
||||||
faultstring: "Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
faultstring:
|
||||||
|
"Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
||||||
detail: {
|
detail: {
|
||||||
ExceptionInfo: "NOT_LINKED_RETRY",
|
ExceptionInfo: "NOT_LINKED_RETRY",
|
||||||
SonosError: "5",
|
SonosError: "5",
|
||||||
@@ -212,13 +215,18 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
|||||||
itemType: "container",
|
itemType: "container",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(),
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
iconForGenre(genre.name),
|
||||||
|
genre.name
|
||||||
|
).href(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlist = (playlist: PlaylistSummary) => ({
|
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
||||||
itemType: "playlist",
|
itemType: "playlist",
|
||||||
id: `playlist:${playlist.id}`,
|
id: `playlist:${playlist.id}`,
|
||||||
title: playlist.name,
|
title: playlist.name,
|
||||||
|
albumArtURI: playlistAlbumArtURL(bonobUrl, playlist).href(),
|
||||||
canPlay: true,
|
canPlay: true,
|
||||||
attributes: {
|
attributes: {
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
@@ -227,11 +235,32 @@ const playlist = (playlist: PlaylistSummary) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const playlistAlbumArtURL = (
|
||||||
|
bonobUrl: URLBuilder,
|
||||||
|
playlist: Playlist
|
||||||
|
) => {
|
||||||
|
const ids = uniq(playlist.entries.map((it) => it.album?.id).filter((it) => it));
|
||||||
|
if (ids.length == 0) {
|
||||||
|
return iconArtURI(bonobUrl, "error");
|
||||||
|
} else {
|
||||||
|
return bonobUrl.append({
|
||||||
|
pathname: `/art/album/${ids.slice(0, 9).join("&")}/size/180`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
|
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
|
||||||
bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` });
|
bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` });
|
||||||
|
|
||||||
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) =>
|
export const iconArtURI = (
|
||||||
bonobUrl.append({ pathname: `/icon/${icon}/size/legacy`, searchParams: text ? { text } : {} });
|
bonobUrl: URLBuilder,
|
||||||
|
icon: ICON,
|
||||||
|
text: string | undefined = undefined
|
||||||
|
) =>
|
||||||
|
bonobUrl.append({
|
||||||
|
pathname: `/icon/${icon}/size/legacy`,
|
||||||
|
searchParams: text ? { text } : {},
|
||||||
|
});
|
||||||
|
|
||||||
export const defaultArtistArtURI = (
|
export const defaultArtistArtURI = (
|
||||||
bonobUrl: URLBuilder,
|
bonobUrl: URLBuilder,
|
||||||
@@ -366,7 +395,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getMediaURI: async (
|
getMediaURI: async (
|
||||||
{ id }: { id: string },
|
{ id }: { id: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
@@ -386,22 +415,19 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getMediaMetadata: async (
|
getMediaMetadata: async (
|
||||||
{ id }: { id: string },
|
{ id }: { id: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
||||||
musicLibrary.track(typeId!).then((it) => ({
|
musicLibrary.track(typeId!).then((it) => ({
|
||||||
getMediaMetadataResult: track(
|
getMediaMetadataResult: track(urlWithToken(accessToken), it),
|
||||||
urlWithToken(accessToken),
|
|
||||||
it
|
|
||||||
),
|
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
search: async (
|
search: async (
|
||||||
{ id, term }: { id: string; term: string },
|
{ id, term }: { id: string; term: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
@@ -446,7 +472,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
}: // recursive,
|
}: // recursive,
|
||||||
{ id: string; index: number; count: number; recursive: boolean },
|
{ id: string; index: number; count: number; recursive: boolean },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
@@ -467,7 +493,8 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
album(urlWithToken(accessToken), it)
|
album(urlWithToken(accessToken), it)
|
||||||
),
|
),
|
||||||
relatedBrowse:
|
relatedBrowse:
|
||||||
artist.similarArtists.filter(it => it.inLibrary).length > 0
|
artist.similarArtists.filter((it) => it.inLibrary)
|
||||||
|
.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: `relatedArtists:${artist.id}`,
|
id: `relatedArtists:${artist.id}`,
|
||||||
@@ -535,7 +562,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
{ id: string; index: number; count: number; recursive: boolean },
|
{ id: string; index: number; count: number; recursive: boolean },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders,
|
||||||
{ headers }: Pick<Request, 'headers'>
|
{ headers }: Pick<Request, "headers">
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
@@ -606,19 +633,28 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
{
|
{
|
||||||
id: "recentlyAdded",
|
id: "recentlyAdded",
|
||||||
title: lang("recentlyAdded"),
|
title: lang("recentlyAdded"),
|
||||||
albumArtURI: iconArtURI(bonobUrl, "recentlyAdded").href(),
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
"recentlyAdded"
|
||||||
|
).href(),
|
||||||
itemType: "albumList",
|
itemType: "albumList",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "recentlyPlayed",
|
id: "recentlyPlayed",
|
||||||
title: lang("recentlyPlayed"),
|
title: lang("recentlyPlayed"),
|
||||||
albumArtURI: iconArtURI(bonobUrl, "recentlyPlayed").href(),
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
"recentlyPlayed"
|
||||||
|
).href(),
|
||||||
itemType: "albumList",
|
itemType: "albumList",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mostPlayed",
|
id: "mostPlayed",
|
||||||
title: lang("mostPlayed"),
|
title: lang("mostPlayed"),
|
||||||
albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(),
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
"mostPlayed"
|
||||||
|
).href(),
|
||||||
itemType: "albumList",
|
itemType: "albumList",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -628,9 +664,21 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
case "search":
|
case "search":
|
||||||
return getMetadataResult({
|
return getMetadataResult({
|
||||||
mediaCollection: [
|
mediaCollection: [
|
||||||
{ itemType: "search", id: "artists", title: lang("artists") },
|
{
|
||||||
{ itemType: "search", id: "albums", title: lang("albums") },
|
itemType: "search",
|
||||||
{ itemType: "search", id: "tracks", title: lang("tracks") },
|
id: "artists",
|
||||||
|
title: lang("artists"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemType: "search",
|
||||||
|
id: "albums",
|
||||||
|
title: lang("albums"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemType: "search",
|
||||||
|
id: "tracks",
|
||||||
|
title: lang("tracks"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
index: 0,
|
index: 0,
|
||||||
total: 3,
|
total: 3,
|
||||||
@@ -688,7 +736,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
.then(slice2(paging))
|
.then(slice2(paging))
|
||||||
.then(([page, total]) =>
|
.then(([page, total]) =>
|
||||||
getMetadataResult({
|
getMetadataResult({
|
||||||
mediaCollection: page.map(it => genre(bonobUrl, it)),
|
mediaCollection: page.map((it) =>
|
||||||
|
genre(bonobUrl, it)
|
||||||
|
),
|
||||||
index: paging._index,
|
index: paging._index,
|
||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
@@ -696,14 +746,23 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
case "playlists":
|
case "playlists":
|
||||||
return musicLibrary
|
return musicLibrary
|
||||||
.playlists()
|
.playlists()
|
||||||
|
.then((it) =>
|
||||||
|
Promise.all(
|
||||||
|
it.map((playlist) =>
|
||||||
|
musicLibrary.playlist(playlist.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
.then(slice2(paging))
|
.then(slice2(paging))
|
||||||
.then(([page, total]) =>
|
.then(([page, total]) => {
|
||||||
getMetadataResult({
|
return getMetadataResult({
|
||||||
mediaCollection: page.map(playlist),
|
mediaCollection: page.map((it) =>
|
||||||
|
playlist(urlWithToken(accessToken), it)
|
||||||
|
),
|
||||||
index: paging._index,
|
index: paging._index,
|
||||||
total,
|
total,
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return musicLibrary
|
return musicLibrary
|
||||||
.playlist(typeId!)
|
.playlist(typeId!)
|
||||||
@@ -736,7 +795,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
return musicLibrary
|
return musicLibrary
|
||||||
.artist(typeId!)
|
.artist(typeId!)
|
||||||
.then((artist) => artist.similarArtists)
|
.then((artist) => artist.similarArtists)
|
||||||
.then(similarArtists => similarArtists.filter(it => it.inLibrary))
|
.then((similarArtists) =>
|
||||||
|
similarArtists.filter((it) => it.inLibrary)
|
||||||
|
)
|
||||||
.then(slice2(paging))
|
.then(slice2(paging))
|
||||||
.then(([page, total]) => {
|
.then(([page, total]) => {
|
||||||
return getMetadataResult({
|
return getMetadataResult({
|
||||||
@@ -767,7 +828,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
createContainer: async (
|
createContainer: async (
|
||||||
{ title, seedId }: { title: string; seedId: string | undefined },
|
{ title, seedId }: { title: string; seedId: string | undefined },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(({ musicLibrary }) =>
|
.then(({ musicLibrary }) =>
|
||||||
@@ -793,7 +854,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
deleteContainer: async (
|
deleteContainer: async (
|
||||||
{ id }: { id: string },
|
{ id }: { id: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
||||||
@@ -801,7 +862,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
addToContainer: async (
|
addToContainer: async (
|
||||||
{ id, parentId }: { id: string; parentId: string },
|
{ id, parentId }: { id: string; parentId: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
@@ -812,7 +873,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
removeFromContainer: async (
|
removeFromContainer: async (
|
||||||
{ id, indices }: { id: string; indices: string },
|
{ id, indices }: { id: string; indices: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
@@ -835,7 +896,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
setPlayedSeconds: async (
|
setPlayedSeconds: async (
|
||||||
{ id, seconds }: { id: string; seconds: string },
|
{ id, seconds }: { id: string; seconds: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
) =>
|
) =>
|
||||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
|
|||||||
7
src/utils.ts
Normal file
7
src/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function takeWithRepeats<T>(things:T[], count: number) {
|
||||||
|
const result = [];
|
||||||
|
for(let i = 0; i < count; i++) {
|
||||||
|
result.push(things[i % things.length])
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -259,7 +259,9 @@ describe("scenarios", () => {
|
|||||||
bonob,
|
bonob,
|
||||||
bonobUrl,
|
bonobUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
{
|
||||||
|
linkCodes: () => linkCodes
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
||||||
@@ -275,7 +277,9 @@ describe("scenarios", () => {
|
|||||||
bonob,
|
bonob,
|
||||||
bonobUrl,
|
bonobUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
{
|
||||||
|
linkCodes: () => linkCodes
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
||||||
@@ -291,7 +295,9 @@ describe("scenarios", () => {
|
|||||||
bonob,
|
bonob,
|
||||||
bonobUrl,
|
bonobUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
{
|
||||||
|
linkCodes: () => linkCodes
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { v4 as uuid } from "uuid";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import Image from "image-js";
|
import Image from "image-js";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
import { MusicService } from "../src/music_service";
|
import { MusicService } from "../src/music_service";
|
||||||
import makeServer, {
|
import makeServer, {
|
||||||
@@ -502,9 +504,11 @@ describe("server", () => {
|
|||||||
theService,
|
theService,
|
||||||
bonobUrl,
|
bonobUrl,
|
||||||
musicService as unknown as MusicService,
|
musicService as unknown as MusicService,
|
||||||
linkCodes as unknown as LinkCodes,
|
{
|
||||||
accessTokens as unknown as AccessTokens,
|
linkCodes: () => linkCodes as unknown as LinkCodes,
|
||||||
clock
|
accessTokens: () => accessTokens as unknown as AccessTokens,
|
||||||
|
clock,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
it("should return the login page", async () => {
|
it("should return the login page", async () => {
|
||||||
@@ -626,8 +630,10 @@ describe("server", () => {
|
|||||||
aService(),
|
aService(),
|
||||||
bonobUrl,
|
bonobUrl,
|
||||||
musicService as unknown as MusicService,
|
musicService as unknown as MusicService,
|
||||||
new InMemoryLinkCodes(),
|
{
|
||||||
accessTokens
|
linkCodes: () => new InMemoryLinkCodes(),
|
||||||
|
accessTokens: () => accessTokens,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const authToken = uuid();
|
const authToken = uuid();
|
||||||
@@ -1055,14 +1061,25 @@ describe("server", () => {
|
|||||||
aService(),
|
aService(),
|
||||||
url("http://localhost:1234"),
|
url("http://localhost:1234"),
|
||||||
musicService as unknown as MusicService,
|
musicService as unknown as MusicService,
|
||||||
new InMemoryLinkCodes(),
|
{
|
||||||
accessTokens
|
linkCodes: () => new InMemoryLinkCodes(),
|
||||||
|
accessTokens: () => accessTokens,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const authToken = uuid();
|
const authToken = uuid();
|
||||||
const albumId = uuid();
|
const albumId = uuid();
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
|
|
||||||
|
const coverArtResponse = (
|
||||||
|
opt: Partial<{ status: number; contentType: string; data: Buffer }>
|
||||||
|
) => ({
|
||||||
|
status: 200,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
data: Buffer.from(uuid(), "ascii"),
|
||||||
|
...opt,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
accessToken = accessTokens.mint(authToken);
|
accessToken = accessTokens.mint(authToken);
|
||||||
});
|
});
|
||||||
@@ -1102,7 +1119,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
describe("artist art", () => {
|
describe("artist art", () => {
|
||||||
["0", "-1", "foo"].forEach((size) => {
|
["0", "-1", "foo"].forEach((size) => {
|
||||||
describe(`when the size is ${size}`, () => {
|
describe(`invalid size of ${size}`, () => {
|
||||||
it(`should return a 400`, async () => {
|
it(`should return a 400`, async () => {
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
@@ -1116,51 +1133,290 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when there is some", () => {
|
describe("fetching a single image", () => {
|
||||||
it("should return the image and a 200", async () => {
|
describe("when the images is available", () => {
|
||||||
const coverArt = {
|
it("should return the image and a 200", async () => {
|
||||||
status: 200,
|
const coverArt = coverArtResponse({});
|
||||||
contentType: "image/jpeg",
|
|
||||||
data: Buffer.from("some image", "ascii"),
|
|
||||||
};
|
|
||||||
|
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
musicLibrary.coverArt.mockResolvedValue(coverArt);
|
musicLibrary.coverArt.mockResolvedValue(coverArt);
|
||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(
|
.get(
|
||||||
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
)
|
)
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
expect(res.status).toEqual(coverArt.status);
|
expect(res.status).toEqual(coverArt.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
coverArt.contentType
|
coverArt.contentType
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||||
albumId,
|
albumId,
|
||||||
"artist",
|
"artist",
|
||||||
180
|
180
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the image is not available", () => {
|
||||||
|
it("should return a 404", async () => {
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
|
musicLibrary.coverArt.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when there isn't one", () => {
|
describe("fetching multiple images as a collage", () => {
|
||||||
it("should return a 404", async () => {
|
const png = fs.readFileSync(path.join(__dirname, '..', 'docs', 'images', 'chartreuseFuchsia.png'));
|
||||||
musicService.login.mockResolvedValue(musicLibrary);
|
|
||||||
|
|
||||||
musicLibrary.coverArt.mockResolvedValue(undefined);
|
describe("fetching a collage of 4 when all are available", () => {
|
||||||
|
it("should return the image and a 200", async () => {
|
||||||
|
const ids = ["1", "2", "3", "4"];
|
||||||
|
|
||||||
const res = await request(server)
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
.get(
|
|
||||||
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
|
||||||
)
|
|
||||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
|
||||||
|
|
||||||
expect(res.status).toEqual(404);
|
ids.forEach((_) => {
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
|
coverArtResponse({
|
||||||
|
data: png,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${ids.join(
|
||||||
|
"&"
|
||||||
|
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||||
|
id,
|
||||||
|
"artist",
|
||||||
|
200
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = await Image.load(res.body);
|
||||||
|
expect(image.width).toEqual(200);
|
||||||
|
expect(image.height).toEqual(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching a collage of 4, however only 1 is available", () => {
|
||||||
|
it("should return the single image", async () => {
|
||||||
|
const ids = ["1", "2", "3", "4"];
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
|
coverArtResponse({
|
||||||
|
data: png,
|
||||||
|
contentType: "image/some-mime-type"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${ids.join(
|
||||||
|
"&"
|
||||||
|
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.header["content-type"]).toEqual("image/some-mime-type");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching a collage of 4 and all are missing", () => {
|
||||||
|
it("should return a 404", async () => {
|
||||||
|
const ids = ["1", "2", "3", "4"];
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
|
ids.forEach((_) => {
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${ids.join(
|
||||||
|
"&"
|
||||||
|
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching a collage of 9 when all are available", () => {
|
||||||
|
it("should return the image and a 200", async () => {
|
||||||
|
const ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
|
ids.forEach((_) => {
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
|
coverArtResponse({
|
||||||
|
data: png,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${ids.join(
|
||||||
|
"&"
|
||||||
|
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||||
|
id,
|
||||||
|
"artist",
|
||||||
|
180
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = await Image.load(res.body);
|
||||||
|
expect(image.width).toEqual(180);
|
||||||
|
expect(image.height).toEqual(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching a collage of 9 when only 2 are available", () => {
|
||||||
|
it("should still return an image and a 200", async () => {
|
||||||
|
const ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
|
coverArtResponse({
|
||||||
|
data: png,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
|
coverArtResponse({
|
||||||
|
data: png,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${ids.join(
|
||||||
|
"&"
|
||||||
|
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||||
|
id,
|
||||||
|
"artist",
|
||||||
|
180
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = await Image.load(res.body);
|
||||||
|
expect(image.width).toEqual(180);
|
||||||
|
expect(image.height).toEqual(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetching a collage of 11", () => {
|
||||||
|
it("should still return an image and a 200, though will only display 9", async () => {
|
||||||
|
const ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"];
|
||||||
|
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
|
ids.forEach((_) => {
|
||||||
|
musicLibrary.coverArt.mockResolvedValueOnce(
|
||||||
|
coverArtResponse({
|
||||||
|
data: png,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${ids.join(
|
||||||
|
"&"
|
||||||
|
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.header["content-type"]).toEqual("image/png");
|
||||||
|
|
||||||
|
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||||
|
id,
|
||||||
|
"artist",
|
||||||
|
180
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = await Image.load(res.body);
|
||||||
|
expect(image.width).toEqual(180);
|
||||||
|
expect(image.height).toEqual(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the image is not available", () => {
|
||||||
|
it("should return a 404", async () => {
|
||||||
|
musicService.login.mockResolvedValue(musicLibrary);
|
||||||
|
|
||||||
|
musicLibrary.coverArt.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const res = await request(server)
|
||||||
|
.get(
|
||||||
|
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||||
|
)
|
||||||
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
|
expect(res.status).toEqual(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1274,10 +1530,12 @@ describe("server", () => {
|
|||||||
aService(),
|
aService(),
|
||||||
url("http://localhost:1234"),
|
url("http://localhost:1234"),
|
||||||
jest.fn() as unknown as MusicService,
|
jest.fn() as unknown as MusicService,
|
||||||
new InMemoryLinkCodes(),
|
{
|
||||||
jest.fn() as unknown as AccessTokens,
|
linkCodes: () => new InMemoryLinkCodes(),
|
||||||
clock,
|
accessTokens: () => jest.fn() as unknown as AccessTokens,
|
||||||
iconColors
|
clock,
|
||||||
|
iconColors,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("invalid icon names", () => {
|
describe("invalid icon names", () => {
|
||||||
@@ -1329,6 +1587,7 @@ describe("server", () => {
|
|||||||
"recentlyPlayed",
|
"recentlyPlayed",
|
||||||
"mostPlayed",
|
"mostPlayed",
|
||||||
"discover",
|
"discover",
|
||||||
|
"error",
|
||||||
].forEach((type) => {
|
].forEach((type) => {
|
||||||
describe(`type=${type}`, () => {
|
describe(`type=${type}`, () => {
|
||||||
describe(`legacy icon`, () => {
|
describe(`legacy icon`, () => {
|
||||||
@@ -1364,9 +1623,12 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return icon colors as per config if overriden", async () => {
|
it("should return icon colors as per config if overriden", async () => {
|
||||||
const response = await request(server(SystemClock, { foregroundColor: 'brightblue', backgroundColor: 'brightpink' })).get(
|
const response = await request(
|
||||||
`/icon/${type}/size/180`
|
server(SystemClock, {
|
||||||
);
|
foregroundColor: "brightblue",
|
||||||
|
backgroundColor: "brightpink",
|
||||||
|
})
|
||||||
|
).get(`/icon/${type}/size/180`);
|
||||||
|
|
||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
const svg = Buffer.from(response.body).toString();
|
const svg = Buffer.from(response.body).toString();
|
||||||
@@ -1385,9 +1647,9 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return a christmas icon on christmas day", async () => {
|
it("should return a christmas icon on christmas day", async () => {
|
||||||
const response = await request(server({ now: () => dayjs("2022/12/25") })).get(
|
const response = await request(
|
||||||
`/icon/${type}/size/180`
|
server({ now: () => dayjs("2022/12/25") })
|
||||||
);
|
).get(`/icon/${type}/size/180`);
|
||||||
|
|
||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
const svg = Buffer.from(response.body).toString();
|
const svg = Buffer.from(response.body).toString();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
defaultArtistArtURI,
|
defaultArtistArtURI,
|
||||||
searchResult,
|
searchResult,
|
||||||
iconArtURI,
|
iconArtURI,
|
||||||
|
playlistAlbumArtURL,
|
||||||
} from "../src/smapi";
|
} from "../src/smapi";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
albumToAlbumSummary,
|
albumToAlbumSummary,
|
||||||
artistToArtistSummary,
|
artistToArtistSummary,
|
||||||
MusicService,
|
MusicService,
|
||||||
|
playlistToPlaylistSummary,
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
import { AccessTokens } from "../src/access_tokens";
|
import { AccessTokens } from "../src/access_tokens";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -158,7 +160,6 @@ describe("service config", () => {
|
|||||||
expect(imageSizeMap(size)).toEqual(`/size/${size}`);
|
expect(imageSizeMap(size)).toEqual(`/size/${size}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -303,6 +304,81 @@ describe("album", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("playlistAlbumArtURL", () => {
|
||||||
|
describe("when the playlist has no albumIds", () => {
|
||||||
|
it("should return question mark icon", () => {
|
||||||
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
|
const playlist = aPlaylist({
|
||||||
|
entries: [aTrack({ album: undefined }), aTrack({ album: undefined })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/icon/error/size/legacy?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the playlist has 2 distinct albumIds", () => {
|
||||||
|
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({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/art/album/1&2/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the playlist has 4 distinct albumIds", () => {
|
||||||
|
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({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/art/album/1&2&3&4/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the playlist has 9 distinct albumIds", () => {
|
||||||
|
it("should return 9 of the ids on the url", () => {
|
||||||
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
|
const playlist = aPlaylist({
|
||||||
|
entries: [
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "5" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "6" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "7" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "8" })) }),
|
||||||
|
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "9" })) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/art/album/1&2&3&4&5&6&7&8&9/size/180?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("defaultAlbumArtURI", () => {
|
describe("defaultAlbumArtURI", () => {
|
||||||
it("should create the correct URI", () => {
|
it("should create the correct URI", () => {
|
||||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
@@ -381,9 +457,11 @@ describe("api", () => {
|
|||||||
service,
|
service,
|
||||||
bonobUrl,
|
bonobUrl,
|
||||||
musicService as unknown as MusicService,
|
musicService as unknown as MusicService,
|
||||||
linkCodes as unknown as LinkCodes,
|
{
|
||||||
accessTokens as unknown as AccessTokens,
|
linkCodes: () => linkCodes as unknown as LinkCodes,
|
||||||
clock
|
accessTokens: () => accessTokens as unknown as AccessTokens,
|
||||||
|
clock,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -940,7 +1018,11 @@ describe("api", () => {
|
|||||||
itemType: "container",
|
itemType: "container",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(),
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
iconForGenre(genre.name),
|
||||||
|
genre.name
|
||||||
|
).href(),
|
||||||
})),
|
})),
|
||||||
index: 0,
|
index: 0,
|
||||||
total: expectedGenres.length,
|
total: expectedGenres.length,
|
||||||
@@ -962,7 +1044,11 @@ describe("api", () => {
|
|||||||
itemType: "container",
|
itemType: "container",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(),
|
albumArtURI: iconArtURI(
|
||||||
|
bonobUrl,
|
||||||
|
iconForGenre(genre.name),
|
||||||
|
genre.name
|
||||||
|
).href(),
|
||||||
})),
|
})),
|
||||||
index: 1,
|
index: 1,
|
||||||
total: expectedGenres.length,
|
total: expectedGenres.length,
|
||||||
@@ -973,30 +1059,40 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for playlists", () => {
|
describe("asking for playlists", () => {
|
||||||
const expectedPlayLists = [
|
const playlist1 = aPlaylist({ id: "1", name: "pl1" });
|
||||||
{ id: "1", name: "pl1" },
|
const playlist2 = aPlaylist({ id: "2", name: "pl2" });
|
||||||
{ id: "2", name: "pl2" },
|
const playlist3 = aPlaylist({ id: "3", name: "pl3" });
|
||||||
{ id: "3", name: "pl3" },
|
const playlist4 = aPlaylist({ id: "4", name: "pl4" });
|
||||||
{ id: "4", name: "pl4" },
|
|
||||||
];
|
const playlists = [playlist1, playlist2, playlist3, playlist4];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
musicLibrary.playlists.mockResolvedValue(expectedPlayLists);
|
musicLibrary.playlists.mockResolvedValue(
|
||||||
|
playlists.map(playlistToPlaylistSummary)
|
||||||
|
);
|
||||||
|
musicLibrary.playlist.mockResolvedValueOnce(playlist1);
|
||||||
|
musicLibrary.playlist.mockResolvedValueOnce(playlist2);
|
||||||
|
musicLibrary.playlist.mockResolvedValueOnce(playlist3);
|
||||||
|
musicLibrary.playlist.mockResolvedValueOnce(playlist4);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for all playlists", () => {
|
describe("asking for all playlists", () => {
|
||||||
it("should return a collection of playlists", async () => {
|
it("should return a collection of playlists", async () => {
|
||||||
const result = await ws.getMetadataAsync({
|
const result = await ws.getMetadataAsync({
|
||||||
id: `playlists`,
|
id: "playlists",
|
||||||
index: 0,
|
index: 0,
|
||||||
count: 100,
|
count: 100,
|
||||||
});
|
});
|
||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult({
|
||||||
mediaCollection: expectedPlayLists.map((playlist) => ({
|
mediaCollection: playlists.map((playlist) => ({
|
||||||
itemType: "playlist",
|
itemType: "playlist",
|
||||||
id: `playlist:${playlist.id}`,
|
id: `playlist:${playlist.id}`,
|
||||||
title: playlist.name,
|
title: playlist.name,
|
||||||
|
albumArtURI: playlistAlbumArtURL(
|
||||||
|
bonobUrlWithAccessToken,
|
||||||
|
playlist
|
||||||
|
).href(),
|
||||||
canPlay: true,
|
canPlay: true,
|
||||||
attributes: {
|
attributes: {
|
||||||
readOnly: "false",
|
readOnly: "false",
|
||||||
@@ -1005,7 +1101,7 @@ describe("api", () => {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
index: 0,
|
index: 0,
|
||||||
total: expectedPlayLists.length,
|
total: playlists.length,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1020,22 +1116,25 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult({
|
||||||
mediaCollection: [
|
mediaCollection: [playlists[1]!, playlists[2]!].map(
|
||||||
expectedPlayLists[1]!,
|
(playlist) => ({
|
||||||
expectedPlayLists[2]!,
|
itemType: "playlist",
|
||||||
].map((playlist) => ({
|
id: `playlist:${playlist.id}`,
|
||||||
itemType: "playlist",
|
title: playlist.name,
|
||||||
id: `playlist:${playlist.id}`,
|
albumArtURI: playlistAlbumArtURL(
|
||||||
title: playlist.name,
|
bonobUrlWithAccessToken,
|
||||||
canPlay: true,
|
playlist
|
||||||
attributes: {
|
).href(),
|
||||||
readOnly: "false",
|
canPlay: true,
|
||||||
userContent: "false",
|
attributes: {
|
||||||
renameable: "false",
|
readOnly: "false",
|
||||||
},
|
userContent: "false",
|
||||||
})),
|
renameable: "false",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
index: 1,
|
index: 1,
|
||||||
total: expectedPlayLists.length,
|
total: playlists.length,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,15 +138,19 @@ describe("URLBuilder", () => {
|
|||||||
describe("with URLSearchParams", () => {
|
describe("with URLSearchParams", () => {
|
||||||
it("should return a new URLBuilder with the new search params appended", () => {
|
it("should return a new URLBuilder with the new search params appended", () => {
|
||||||
const original = url("https://example.com/some-path?a=b&c=d");
|
const original = url("https://example.com/some-path?a=b&c=d");
|
||||||
|
const searchParams = new URLSearchParams({ x: "y" });
|
||||||
|
searchParams.append("z", "1");
|
||||||
|
searchParams.append("z", "2");
|
||||||
|
|
||||||
const updated = original.append({
|
const updated = original.append({
|
||||||
searchParams: new URLSearchParams({ x: "y", z: "1" }),
|
searchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
||||||
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
||||||
|
|
||||||
expect(updated.href()).toEqual("https://example.com/some-path?a=b&c=d&x=y&z=1");
|
expect(updated.href()).toEqual("https://example.com/some-path?a=b&c=d&x=y&z=1&z=2");
|
||||||
expect(`${updated.searchParams()}`).toEqual("a=b&c=d&x=y&z=1")
|
expect(`${updated.searchParams()}`).toEqual("a=b&c=d&x=y&z=1&z=2")
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -168,15 +172,19 @@ describe("URLBuilder", () => {
|
|||||||
|
|
||||||
it("should return a new URLBuilder with the new search params", () => {
|
it("should return a new URLBuilder with the new search params", () => {
|
||||||
const original = url("https://example.com/some-path?a=b&c=d");
|
const original = url("https://example.com/some-path?a=b&c=d");
|
||||||
|
const searchParams = new URLSearchParams({ x: "y" });
|
||||||
|
searchParams.append("z", "1");
|
||||||
|
searchParams.append("z", "2");
|
||||||
|
|
||||||
const updated = original.with({
|
const updated = original.with({
|
||||||
searchParams: { x: "y", z: "1" },
|
searchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
||||||
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
||||||
|
|
||||||
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1");
|
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1&z=2");
|
||||||
expect(`${updated.searchParams()}`).toEqual("x=y&z=1")
|
expect(`${updated.searchParams()}`).toEqual("x=y&z=1&z=2")
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,15 +204,19 @@ describe("URLBuilder", () => {
|
|||||||
|
|
||||||
it("should return a new URLBuilder with the new search params", () => {
|
it("should return a new URLBuilder with the new search params", () => {
|
||||||
const original = url("https://example.com/some-path?a=b&c=d");
|
const original = url("https://example.com/some-path?a=b&c=d");
|
||||||
|
const searchParams = new URLSearchParams({ x: "y" });
|
||||||
|
searchParams.append("z", "1");
|
||||||
|
searchParams.append("z", "2");
|
||||||
|
|
||||||
const updated = original.with({
|
const updated = original.with({
|
||||||
searchParams: new URLSearchParams({ x: "y", z: "1" }),
|
searchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
||||||
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
||||||
|
|
||||||
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1");
|
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1&z=2");
|
||||||
expect(`${updated.searchParams()}`).toEqual("x=y&z=1")
|
expect(`${updated.searchParams()}`).toEqual("x=y&z=1&z=2")
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
35
tests/utils.test.ts
Normal file
35
tests/utils.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { takeWithRepeats } from "../src/utils";
|
||||||
|
|
||||||
|
describe("takeWithRepeat", () => {
|
||||||
|
describe("when there is nothing in the input", () => {
|
||||||
|
it("should return an array of undefineds", () => {
|
||||||
|
expect(takeWithRepeats([], 3)).toEqual([undefined, undefined, undefined]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are exactly the amount required", () => {
|
||||||
|
it("should return them all", () => {
|
||||||
|
expect(takeWithRepeats(["a", undefined, "c"], 3)).toEqual([
|
||||||
|
"a",
|
||||||
|
undefined,
|
||||||
|
"c",
|
||||||
|
]);
|
||||||
|
expect(takeWithRepeats(["a"], 1)).toEqual(["a"]);
|
||||||
|
expect(takeWithRepeats([undefined], 1)).toEqual([undefined]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there are less than the amount required", () => {
|
||||||
|
it("should cycle through the ones available", () => {
|
||||||
|
expect(takeWithRepeats(["a", "b"], 3)).toEqual(["a", "b", "a"]);
|
||||||
|
expect(takeWithRepeats(["a", "b"], 5)).toEqual(["a", "b", "a", "b", "a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when there more than the amount required", () => {
|
||||||
|
it("should return the first n items", () => {
|
||||||
|
expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]);
|
||||||
|
expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual(["a", undefined]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
4
web/icons/Error-82783.svg
Normal file
4
web/icons/Error-82783.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M21.382,18.967L12.585,4.331c-0.265-0.441-0.904-0.441-1.169,0L2.618,18.967C2.345,19.421,2.672,20,3.202,20h17.595C21.328,20,21.655,19.421,21.382,18.967z"/>
|
||||||
|
<path d="M13,18h-2v-2h2V18z M13,14h-2V9h2V14z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 396 B |
Reference in New Issue
Block a user