Rendering playlist icon collage of 3x3 (#35)

This commit is contained in:
Simon J
2021-08-30 11:51:22 +10:00
committed by GitHub
parent e2e73209a2
commit ae29bc14eb
14 changed files with 750 additions and 177 deletions

View File

@@ -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',
],
}; };

View File

@@ -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"
} }
} }

View File

@@ -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, () => {

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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
View 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;
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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,
}) })
); );
}); });

View File

@@ -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
View 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]);
});
});
});

View 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