From ae29bc14ebc105d5b29df32a720f734c279bda56 Mon Sep 17 00:00:00 2001 From: Simon J Date: Mon, 30 Aug 2021 11:51:22 +1000 Subject: [PATCH] Rendering playlist icon collage of 3x3 (#35) --- jest.config.js | 4 + package.json | 2 +- src/app.ts | 13 +- src/icon.ts | 6 +- src/music_service.ts | 5 + src/server.ts | 143 +++++++++++---- src/smapi.ts | 135 ++++++++++---- src/utils.ts | 7 + tests/scenarios.test.ts | 12 +- tests/server.test.ts | 370 ++++++++++++++++++++++++++++++++------ tests/smapi.test.ts | 161 +++++++++++++---- tests/url_builder.test.ts | 30 +++- tests/utils.test.ts | 35 ++++ web/icons/Error-82783.svg | 4 + 14 files changed, 750 insertions(+), 177 deletions(-) create mode 100644 src/utils.ts create mode 100644 tests/utils.test.ts create mode 100644 web/icons/Error-82783.svg diff --git a/jest.config.js b/jest.config.js index dbbc977..1759888 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', setupFilesAfterEnv: ["/tests/setup.js"], + modulePathIgnorePatterns: [ + '/node_modules', + '/build', + ], }; \ No newline at end of file diff --git a/package.json b/package.json index 3799782..3c8f45e 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,6 @@ "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", "register-dev": "ts-node ./src/register.ts http://$(hostname):4534", - "test": "jest --testPathIgnorePatterns=build" + "test": "jest" } } diff --git a/src/app.ts b/src/app.ts index 09cbd1c..71837c9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -61,11 +61,14 @@ const app = server( bonob, config.bonobUrl, featureFlagAwareMusicService, - new InMemoryLinkCodes(), - new InMemoryAccessTokens(sha256(config.secret)), - SystemClock, - config.icons, - true, + { + linkCodes: () => new InMemoryLinkCodes(), + accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)), + clock: SystemClock, + iconColors: config.icons, + applyContextPath: true, + logRequests: true + } ); app.listen(config.port, () => { diff --git a/src/icon.ts b/src/icon.ts index 33a8005..6a86a97 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -267,7 +267,8 @@ export type ICON = | "trumpet" | "conductor" | "reggae" - | "music"; + | "music" + | "error"; const iconFrom = (name: string) => new SvgIcon( @@ -310,7 +311,8 @@ export const ICONS: Record = { trumpet: iconFrom("Trumpet-17823.svg"), conductor: iconFrom("Music-Conductor-225.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; diff --git a/src/music_service.ts b/src/music_service.ts index 09416f2..ad71fa1 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -120,6 +120,11 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({ 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 TrackStream = { diff --git a/src/server.ts b/src/server.ts index 4e2ab63..b45568d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import { option as O } from "fp-ts"; import express, { Express, Request } from "express"; import * as Eta from "eta"; -import morgan from "morgan"; +// import morgan from "morgan"; import path from "path"; import sharp from "sharp"; @@ -27,6 +27,9 @@ import { pipe } from "fp-ts/lib/function"; import { URLBuilder } from "./url_builder"; import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n"; 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"; @@ -67,24 +70,46 @@ export class RangeBytesFromFilter extends Transform { 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( sonos: Sonos, service: Service, bonobUrl: URLBuilder, musicService: MusicService, - linkCodes: LinkCodes = new InMemoryLinkCodes(), - accessTokens: AccessTokens = new AccessTokenPerAuthToken(), - clock: Clock = SystemClock, - iconColors: { - foregroundColor: string | undefined; - backgroundColor: string | undefined; - } = { foregroundColor: undefined, backgroundColor: undefined }, - applyContextPath = true + opts: Partial = {} ): Express { + const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts }; + + const linkCodes = serverOpts.linkCodes(); + const accessTokens = serverOpts.accessTokens(); + const clock = serverOpts.clock; + const app = express(); const i8n = makeI8N(service.name); - app.use(morgan("combined")); + if (serverOpts.logRequests) { + app.use(morgan("combined")); + } app.use(express.urlencoded({ extended: false })); // todo: pass options in here? @@ -94,6 +119,8 @@ function server( app.set("view engine", "eta"); app.set("views", path.resolve(__dirname, "..", "web", "views")); + app.set("query parser", "simple"); + const langFor = (req: Request) => { logger.debug( `${req.path} (req[accept-language]=${req.headers["accept-language"]})` @@ -387,46 +414,92 @@ function server( }; return Promise.resolve( - makeFestive(icon.with({ text, ...iconColors }), clock).toString() + makeFestive( + icon.with({ text, ...serverOpts.iconColors }), + clock + ).toString() ) .then(spec.responseFormatter) .then((data) => res.status(200).type(spec.mimeType).send(data)); } }); + + const GRAVITY_9 = [ + "north", + "northeast", + "east", + "southeast", + "south", + "southwest", + "west", + "northwest", + "centre", + ]; - app.get("/art/:type/:id/size/:size", (req, res) => { + app.get("/art/:type/:ids/size/:size", (req, res) => { const authToken = accessTokens.authTokenFor( req.query[BONOB_ACCESS_TOKEN_HEADER] as string ); const type = req.params["type"]!; - const id = req.params["id"]!; - const size = req.params["size"]!; + const ids = req.params["ids"]!.split("&"); + const size = Number.parseInt(req.params["size"]!); + if (!authToken) { return res.status(401).send(); } else if (type != "artist" && type != "album") { return res.status(400).send(); - } else if (!(size.match(/^\d+$/) && Number.parseInt(size) > 0)) { + } else if (!(size > 0)) { 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( @@ -440,7 +513,7 @@ function server( i8n ); - if (applyContextPath) { + if (serverOpts.applyContextPath) { const container = express(); container.use(bonobUrl.path(), app); return container; diff --git a/src/smapi.ts b/src/smapi.ts index 0a46f31..ab1aa32 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -5,7 +5,6 @@ import { readFileSync } from "fs"; import path from "path"; import logger from "./logger"; - import { LinkCodes } from "./link_codes"; import { Album, @@ -14,7 +13,7 @@ import { ArtistSummary, Genre, MusicService, - PlaylistSummary, + Playlist, slice2, Track, } from "./music_service"; @@ -24,6 +23,7 @@ import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; import { asLANGs, I8N } from "./i8n"; import { ICON, iconForGenre } from "./icon"; +import { uniq } from "underscore"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; @@ -184,11 +184,14 @@ class SonosSoap { }, }; } 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 { Fault: { 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: { ExceptionInfo: "NOT_LINKED_RETRY", SonosError: "5", @@ -212,13 +215,18 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ itemType: "container", id: `genre:${genre.id}`, 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", id: `playlist:${playlist.id}`, title: playlist.name, + albumArtURI: playlistAlbumArtURL(bonobUrl, playlist).href(), canPlay: true, attributes: { 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) => bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` }); -export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) => - bonobUrl.append({ pathname: `/icon/${icon}/size/legacy`, searchParams: text ? { text } : {} }); +export const iconArtURI = ( + bonobUrl: URLBuilder, + icon: ICON, + text: string | undefined = undefined +) => + bonobUrl.append({ + pathname: `/icon/${icon}/size/legacy`, + searchParams: text ? { text } : {}, + }); export const defaultArtistArtURI = ( bonobUrl: URLBuilder, @@ -338,7 +367,7 @@ function bindSmapiSoapServiceToExpress( i8n: I8N ) { const sonosSoap = new SonosSoap(bonobUrl, linkCodes); - + const urlWithToken = (accessToken: string) => bonobUrl.append({ searchParams: { @@ -366,7 +395,7 @@ function bindSmapiSoapServiceToExpress( getMediaURI: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) @@ -386,22 +415,19 @@ function bindSmapiSoapServiceToExpress( getMediaMetadata: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(async ({ musicLibrary, accessToken, typeId }) => musicLibrary.track(typeId!).then((it) => ({ - getMediaMetadataResult: track( - urlWithToken(accessToken), - it - ), + getMediaMetadataResult: track(urlWithToken(accessToken), it), })) ), search: async ( { id, term }: { id: string; term: string }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) @@ -446,7 +472,7 @@ function bindSmapiSoapServiceToExpress( }: // recursive, { id: string; index: number; count: number; recursive: boolean }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) @@ -467,7 +493,8 @@ function bindSmapiSoapServiceToExpress( album(urlWithToken(accessToken), it) ), relatedBrowse: - artist.similarArtists.filter(it => it.inLibrary).length > 0 + artist.similarArtists.filter((it) => it.inLibrary) + .length > 0 ? [ { id: `relatedArtists:${artist.id}`, @@ -535,7 +562,7 @@ function bindSmapiSoapServiceToExpress( { id: string; index: number; count: number; recursive: boolean }, _, soapyHeaders: SoapyHeaders, - { headers }: Pick + { headers }: Pick ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) @@ -606,19 +633,28 @@ function bindSmapiSoapServiceToExpress( { id: "recentlyAdded", title: lang("recentlyAdded"), - albumArtURI: iconArtURI(bonobUrl, "recentlyAdded").href(), + albumArtURI: iconArtURI( + bonobUrl, + "recentlyAdded" + ).href(), itemType: "albumList", }, { id: "recentlyPlayed", title: lang("recentlyPlayed"), - albumArtURI: iconArtURI(bonobUrl, "recentlyPlayed").href(), + albumArtURI: iconArtURI( + bonobUrl, + "recentlyPlayed" + ).href(), itemType: "albumList", }, { id: "mostPlayed", title: lang("mostPlayed"), - albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(), + albumArtURI: iconArtURI( + bonobUrl, + "mostPlayed" + ).href(), itemType: "albumList", }, ], @@ -628,9 +664,21 @@ function bindSmapiSoapServiceToExpress( case "search": return getMetadataResult({ mediaCollection: [ - { itemType: "search", id: "artists", title: lang("artists") }, - { itemType: "search", id: "albums", title: lang("albums") }, - { itemType: "search", id: "tracks", title: lang("tracks") }, + { + itemType: "search", + id: "artists", + title: lang("artists"), + }, + { + itemType: "search", + id: "albums", + title: lang("albums"), + }, + { + itemType: "search", + id: "tracks", + title: lang("tracks"), + }, ], index: 0, total: 3, @@ -688,7 +736,9 @@ function bindSmapiSoapServiceToExpress( .then(slice2(paging)) .then(([page, total]) => getMetadataResult({ - mediaCollection: page.map(it => genre(bonobUrl, it)), + mediaCollection: page.map((it) => + genre(bonobUrl, it) + ), index: paging._index, total, }) @@ -696,14 +746,23 @@ function bindSmapiSoapServiceToExpress( case "playlists": return musicLibrary .playlists() + .then((it) => + Promise.all( + it.map((playlist) => + musicLibrary.playlist(playlist.id) + ) + ) + ) .then(slice2(paging)) - .then(([page, total]) => - getMetadataResult({ - mediaCollection: page.map(playlist), + .then(([page, total]) => { + return getMetadataResult({ + mediaCollection: page.map((it) => + playlist(urlWithToken(accessToken), it) + ), index: paging._index, total, - }) - ); + }); + }); case "playlist": return musicLibrary .playlist(typeId!) @@ -736,7 +795,9 @@ function bindSmapiSoapServiceToExpress( return musicLibrary .artist(typeId!) .then((artist) => artist.similarArtists) - .then(similarArtists => similarArtists.filter(it => it.inLibrary)) + .then((similarArtists) => + similarArtists.filter((it) => it.inLibrary) + ) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ @@ -767,7 +828,7 @@ function bindSmapiSoapServiceToExpress( createContainer: async ( { title, seedId }: { title: string; seedId: string | undefined }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(({ musicLibrary }) => @@ -793,7 +854,7 @@ function bindSmapiSoapServiceToExpress( deleteContainer: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) @@ -801,7 +862,7 @@ function bindSmapiSoapServiceToExpress( addToContainer: async ( { id, parentId }: { id: string; parentId: string }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) @@ -812,7 +873,7 @@ function bindSmapiSoapServiceToExpress( removeFromContainer: async ( { id, indices }: { id: string; indices: string }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) @@ -835,7 +896,7 @@ function bindSmapiSoapServiceToExpress( setPlayedSeconds: async ( { id, seconds }: { id: string; seconds: string }, _, - soapyHeaders: SoapyHeaders, + soapyHeaders: SoapyHeaders ) => auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c886afc --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,7 @@ +export function takeWithRepeats(things:T[], count: number) { + const result = []; + for(let i = 0; i < count; i++) { + result.push(things[i % things.length]) + } + return result; +} \ No newline at end of file diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index facd734..3978df8 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -259,7 +259,9 @@ describe("scenarios", () => { bonob, bonobUrl, musicService, - linkCodes + { + linkCodes: () => linkCodes + } ); const sonosDriver = new SonosDriver(server, bonobUrl, bonob); @@ -275,7 +277,9 @@ describe("scenarios", () => { bonob, bonobUrl, musicService, - linkCodes + { + linkCodes: () => linkCodes + } ); const sonosDriver = new SonosDriver(server, bonobUrl, bonob); @@ -291,7 +295,9 @@ describe("scenarios", () => { bonob, bonobUrl, musicService, - linkCodes + { + linkCodes: () => linkCodes + } ); const sonosDriver = new SonosDriver(server, bonobUrl, bonob); diff --git a/tests/server.test.ts b/tests/server.test.ts index 85a59d1..6a9da2d 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -2,6 +2,8 @@ import { v4 as uuid } from "uuid"; import dayjs from "dayjs"; import request from "supertest"; import Image from "image-js"; +import fs from "fs"; +import path from "path"; import { MusicService } from "../src/music_service"; import makeServer, { @@ -502,9 +504,11 @@ describe("server", () => { theService, bonobUrl, musicService as unknown as MusicService, - linkCodes as unknown as LinkCodes, - accessTokens as unknown as AccessTokens, - clock + { + linkCodes: () => linkCodes as unknown as LinkCodes, + accessTokens: () => accessTokens as unknown as AccessTokens, + clock, + } ); it("should return the login page", async () => { @@ -626,8 +630,10 @@ describe("server", () => { aService(), bonobUrl, musicService as unknown as MusicService, - new InMemoryLinkCodes(), - accessTokens + { + linkCodes: () => new InMemoryLinkCodes(), + accessTokens: () => accessTokens, + } ); const authToken = uuid(); @@ -1055,14 +1061,25 @@ describe("server", () => { aService(), url("http://localhost:1234"), musicService as unknown as MusicService, - new InMemoryLinkCodes(), - accessTokens + { + linkCodes: () => new InMemoryLinkCodes(), + accessTokens: () => accessTokens, + } ); const authToken = uuid(); const albumId = uuid(); 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(() => { accessToken = accessTokens.mint(authToken); }); @@ -1102,7 +1119,7 @@ describe("server", () => { describe("artist art", () => { ["0", "-1", "foo"].forEach((size) => { - describe(`when the size is ${size}`, () => { + describe(`invalid size of ${size}`, () => { it(`should return a 400`, async () => { musicService.login.mockResolvedValue(musicLibrary); const res = await request(server) @@ -1116,51 +1133,290 @@ describe("server", () => { }); }); - describe("when there is some", () => { - it("should return the image and a 200", async () => { - const coverArt = { - status: 200, - contentType: "image/jpeg", - data: Buffer.from("some image", "ascii"), - }; + describe("fetching a single image", () => { + describe("when the images is available", () => { + it("should return the image and a 200", async () => { + const coverArt = coverArtResponse({}); - musicService.login.mockResolvedValue(musicLibrary); + musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.coverArt.mockResolvedValue(coverArt); + musicLibrary.coverArt.mockResolvedValue(coverArt); - const res = await request(server) - .get( - `/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + 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(coverArt.status); - expect(res.header["content-type"]).toEqual( - coverArt.contentType - ); + expect(res.status).toEqual(coverArt.status); + expect(res.header["content-type"]).toEqual( + coverArt.contentType + ); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - albumId, - "artist", - 180 - ); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.coverArt).toHaveBeenCalledWith( + albumId, + "artist", + 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", () => { - it("should return a 404", async () => { - musicService.login.mockResolvedValue(musicLibrary); + describe("fetching multiple images as a collage", () => { + const png = fs.readFileSync(path.join(__dirname, '..', 'docs', 'images', 'chartreuseFuchsia.png')); - 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) - .get( - `/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + musicService.login.mockResolvedValue(musicLibrary); - 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); + }); }); }); @@ -1263,7 +1519,7 @@ describe("server", () => { describe("/icon", () => { const server = ( - clock: Clock = SystemClock, + clock: Clock = SystemClock, iconColors: { foregroundColor: string | undefined; backgroundColor: string | undefined; @@ -1274,10 +1530,12 @@ describe("server", () => { aService(), url("http://localhost:1234"), jest.fn() as unknown as MusicService, - new InMemoryLinkCodes(), - jest.fn() as unknown as AccessTokens, - clock, - iconColors + { + linkCodes: () => new InMemoryLinkCodes(), + accessTokens: () => jest.fn() as unknown as AccessTokens, + clock, + iconColors, + } ); describe("invalid icon names", () => { @@ -1329,6 +1587,7 @@ describe("server", () => { "recentlyPlayed", "mostPlayed", "discover", + "error", ].forEach((type) => { describe(`type=${type}`, () => { describe(`legacy icon`, () => { @@ -1364,9 +1623,12 @@ describe("server", () => { }); it("should return icon colors as per config if overriden", async () => { - const response = await request(server(SystemClock, { foregroundColor: 'brightblue', backgroundColor: 'brightpink' })).get( - `/icon/${type}/size/180` - ); + const response = await request( + server(SystemClock, { + foregroundColor: "brightblue", + backgroundColor: "brightpink", + }) + ).get(`/icon/${type}/size/180`); expect(response.status).toEqual(200); const svg = Buffer.from(response.body).toString(); @@ -1382,12 +1644,12 @@ describe("server", () => { expect(response.status).toEqual(200); const svg = Buffer.from(response.body).toString(); expect(svg).toContain(`foobar1000`); - }); + }); it("should return a christmas icon on christmas day", async () => { - const response = await request(server({ now: () => dayjs("2022/12/25") })).get( - `/icon/${type}/size/180` - ); + const response = await request( + server({ now: () => dayjs("2022/12/25") }) + ).get(`/icon/${type}/size/180`); expect(response.status).toEqual(200); const svg = Buffer.from(response.body).toString(); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index ff02fd7..33f40a6 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -22,6 +22,7 @@ import { defaultArtistArtURI, searchResult, iconArtURI, + playlistAlbumArtURL, } from "../src/smapi"; import { @@ -43,6 +44,7 @@ import { albumToAlbumSummary, artistToArtistSummary, MusicService, + playlistToPlaylistSummary, } from "../src/music_service"; import { AccessTokens } from "../src/access_tokens"; import dayjs from "dayjs"; @@ -158,7 +160,6 @@ describe("service config", () => { 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", () => { it("should create the correct URI", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); @@ -381,9 +457,11 @@ describe("api", () => { service, bonobUrl, musicService as unknown as MusicService, - linkCodes as unknown as LinkCodes, - accessTokens as unknown as AccessTokens, - clock + { + linkCodes: () => linkCodes as unknown as LinkCodes, + accessTokens: () => accessTokens as unknown as AccessTokens, + clock, + } ); beforeEach(() => { @@ -940,7 +1018,11 @@ describe("api", () => { itemType: "container", id: `genre:${genre.id}`, title: genre.name, - albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(), + albumArtURI: iconArtURI( + bonobUrl, + iconForGenre(genre.name), + genre.name + ).href(), })), index: 0, total: expectedGenres.length, @@ -962,7 +1044,11 @@ describe("api", () => { itemType: "container", id: `genre:${genre.id}`, title: genre.name, - albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(), + albumArtURI: iconArtURI( + bonobUrl, + iconForGenre(genre.name), + genre.name + ).href(), })), index: 1, total: expectedGenres.length, @@ -973,30 +1059,40 @@ describe("api", () => { }); describe("asking for playlists", () => { - const expectedPlayLists = [ - { id: "1", name: "pl1" }, - { id: "2", name: "pl2" }, - { id: "3", name: "pl3" }, - { id: "4", name: "pl4" }, - ]; + const playlist1 = aPlaylist({ id: "1", name: "pl1" }); + const playlist2 = aPlaylist({ id: "2", name: "pl2" }); + const playlist3 = aPlaylist({ id: "3", name: "pl3" }); + const playlist4 = aPlaylist({ id: "4", name: "pl4" }); + + const playlists = [playlist1, playlist2, playlist3, playlist4]; 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", () => { it("should return a collection of playlists", async () => { const result = await ws.getMetadataAsync({ - id: `playlists`, + id: "playlists", index: 0, count: 100, }); expect(result[0]).toEqual( getMetadataResult({ - mediaCollection: expectedPlayLists.map((playlist) => ({ + mediaCollection: playlists.map((playlist) => ({ itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, + albumArtURI: playlistAlbumArtURL( + bonobUrlWithAccessToken, + playlist + ).href(), canPlay: true, attributes: { readOnly: "false", @@ -1005,7 +1101,7 @@ describe("api", () => { }, })), index: 0, - total: expectedPlayLists.length, + total: playlists.length, }) ); }); @@ -1020,22 +1116,25 @@ describe("api", () => { }); expect(result[0]).toEqual( getMetadataResult({ - mediaCollection: [ - expectedPlayLists[1]!, - expectedPlayLists[2]!, - ].map((playlist) => ({ - itemType: "playlist", - id: `playlist:${playlist.id}`, - title: playlist.name, - canPlay: true, - attributes: { - readOnly: "false", - userContent: "false", - renameable: "false", - }, - })), + mediaCollection: [playlists[1]!, playlists[2]!].map( + (playlist) => ({ + itemType: "playlist", + id: `playlist:${playlist.id}`, + title: playlist.name, + albumArtURI: playlistAlbumArtURL( + bonobUrlWithAccessToken, + playlist + ).href(), + canPlay: true, + attributes: { + readOnly: "false", + userContent: "false", + renameable: "false", + }, + }) + ), index: 1, - total: expectedPlayLists.length, + total: playlists.length, }) ); }); diff --git a/tests/url_builder.test.ts b/tests/url_builder.test.ts index aaaffc8..3eea2d8 100644 --- a/tests/url_builder.test.ts +++ b/tests/url_builder.test.ts @@ -138,15 +138,19 @@ describe("URLBuilder", () => { describe("with URLSearchParams", () => { 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 searchParams = new URLSearchParams({ x: "y" }); + searchParams.append("z", "1"); + searchParams.append("z", "2"); + 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.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.searchParams()}`).toEqual("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&z=2") }); }); }); @@ -168,15 +172,19 @@ describe("URLBuilder", () => { it("should return a new URLBuilder with the new search params", () => { 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({ - searchParams: { x: "y", z: "1" }, + searchParams, }); expect(original.href()).toEqual("https://example.com/some-path?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.searchParams()}`).toEqual("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&z=2") }); }); @@ -196,15 +204,19 @@ describe("URLBuilder", () => { it("should return a new URLBuilder with the new search params", () => { 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({ - searchParams: new URLSearchParams({ x: "y", z: "1" }), + searchParams, }); expect(original.href()).toEqual("https://example.com/some-path?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.searchParams()}`).toEqual("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&z=2") }); }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..ce0d5f3 --- /dev/null +++ b/tests/utils.test.ts @@ -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]); + }); + }); +}); diff --git a/web/icons/Error-82783.svg b/web/icons/Error-82783.svg new file mode 100644 index 0000000..624c9a2 --- /dev/null +++ b/web/icons/Error-82783.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file