Loading artist images from navidrome

This commit is contained in:
simojenki
2021-03-03 22:59:15 +11:00
parent ce6c1638fd
commit 4dae907826
8 changed files with 273 additions and 74 deletions

View File

@@ -26,6 +26,11 @@ export type AuthFailure = {
export type Artist = { export type Artist = {
id: string; id: string;
name: string; name: string;
image: {
small: string | undefined,
medium: string | undefined,
large: string | undefined,
}
}; };
export type Album = { export type Album = {

View File

@@ -38,7 +38,12 @@ export type SubsonicResponse = {
export type GetArtistsResponse = SubsonicResponse & { export type GetArtistsResponse = SubsonicResponse & {
artists: { artists: {
index: { index: {
artist: { _id: string; _name: string; _albumCount: string }[]; artist: {
_id: string;
_name: string;
_albumCount: string;
_artistImageUrl: string | undefined;
}[];
_name: string; _name: string;
}[]; }[];
}; };
@@ -51,6 +56,19 @@ export type SubsonicError = SubsonicResponse & {
}; };
}; };
export type artistInfo = {
biography: string | undefined;
musicBrainzId: string | undefined;
lastFmUrl: string | undefined;
smallImageUrl: string | undefined;
mediumImageUrl: string | undefined;
largeImageUrl: string | undefined;
};
export type GetArtistInfoResponse = {
artistInfo: artistInfo;
};
export function isError( export function isError(
subsonicResponse: SubsonicResponse subsonicResponse: SubsonicResponse
): subsonicResponse is SubsonicError { ): subsonicResponse is SubsonicError {
@@ -89,13 +107,15 @@ export class Navidrome implements MusicService {
}); });
generateToken = async (credentials: Credentials) => generateToken = async (credentials: Credentials) =>
this.get(credentials, "/rest/ping.view").then(() => ({ this.get(credentials, "/rest/ping.view")
authToken: Buffer.from( .then(() => ({
JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials))) authToken: Buffer.from(
).toString("base64"), JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials)))
userId: credentials.username, ).toString("base64"),
nickname: credentials.username, userId: credentials.username,
})).catch(e => ({ message: `${e}` })); nickname: credentials.username,
}))
.catch((e) => ({ message: `${e}` }));
parseToken = (token: string): Credentials => parseToken = (token: string): Credentials =>
JSON.parse( JSON.parse(
@@ -113,13 +133,33 @@ export class Navidrome implements MusicService {
.get<GetArtistsResponse>(credentials, "/rest/getArtists") .get<GetArtistsResponse>(credentials, "/rest/getArtists")
.then((it) => it.artists.index.flatMap((it) => it.artist)) .then((it) => it.artists.index.flatMap((it) => it.artist))
.then((artists) => .then((artists) =>
artists.map((it) => ({ id: it._id, name: it._name })) Promise.all(
artists.map((artist) =>
navidrome
.get<GetArtistInfoResponse>(
credentials,
"/rest/getArtistInfo",
{ id: artist._id }
)
.then((it) => it.artistInfo)
.then((artistInfo) => ({
id: artist._id,
name: artist._name,
image: {
small: artistInfo.smallImageUrl,
medium: artistInfo.mediumImageUrl,
large: artistInfo.largeImageUrl,
},
}))
)
)
) )
.then(slice2(q)) .then(slice2(q))
.then(asResult), .then(asResult),
artist: (id: string) => ({ artist: (id: string) => ({
id, id,
name: id, name: id,
image: { small: undefined, medium: undefined, large: undefined },
}), }),
albums: (_: AlbumQuery): Promise<Result<Album>> => { albums: (_: AlbumQuery): Promise<Result<Album>> => {
return Promise.resolve({ results: [], total: 0 }); return Promise.resolve({ results: [], total: 0 });

View File

@@ -6,7 +6,7 @@ import path from "path";
import logger from "./logger"; import logger from "./logger";
import { LinkCodes } from "./link_codes"; import { LinkCodes } from "./link_codes";
import { Artist, MusicLibrary, MusicService } from "./music_service"; import { Album, Artist, MusicLibrary, MusicService } from "./music_service";
export const LOGIN_ROUTE = "/login"; export const LOGIN_ROUTE = "/login";
export const SOAP_PATH = "/ws/sonos"; export const SOAP_PATH = "/ws/sonos";
@@ -218,8 +218,8 @@ function bindSmapiSoapServiceToExpress(
case "root": case "root":
return getMetadataResult({ return getMetadataResult({
mediaCollection: [ mediaCollection: [
container({ id: "artists", title: "Artists" }), { itemType: "container", id: "artists", title: "Artists" },
container({ id: "albums", title: "Albums" }), { itemType: "container", id: "albums", title: "Albums" },
], ],
index: 0, index: 0,
total: 2, total: 2,
@@ -230,9 +230,12 @@ function bindSmapiSoapServiceToExpress(
.then(({ results, total }: { results: Artist[], total: number}) => .then(({ results, total }: { results: Artist[], total: number}) =>
getMetadataResult({ getMetadataResult({
mediaCollection: results.map((it) => mediaCollection: results.map((it) =>
container({ ({
itemType: "artist",
id: `artist:${it.id}`, id: `artist:${it.id}`,
artistId: it.id,
title: it.name, title: it.name,
albumArtURI: it.image.small
}) })
), ),
index: paging._index, index: paging._index,
@@ -242,7 +245,7 @@ function bindSmapiSoapServiceToExpress(
case "albums": case "albums":
return await musicLibrary return await musicLibrary
.albums(paging) .albums(paging)
.then(({ results, total }: { results: Artist[], total: number}) => .then(({ results, total }: { results: Album[], total: number}) =>
getMetadataResult({ getMetadataResult({
mediaCollection: results.map((it) => mediaCollection: results.map((it) =>
container({ container({

View File

@@ -80,6 +80,11 @@ export const BOB_MARLEY: ArtistWithAlbums = {
{ id: uuid(), name: "Exodus" }, { id: uuid(), name: "Exodus" },
{ id: uuid(), name: "Kaya" }, { id: uuid(), name: "Kaya" },
], ],
image: {
small: "http://localhost/BOB_MARLEY/sml",
medium: "http://localhost/BOB_MARLEY/med",
large: "http://localhost/BOB_MARLEY/lge",
}
}; };
export const BLONDIE: ArtistWithAlbums = { export const BLONDIE: ArtistWithAlbums = {
@@ -89,12 +94,22 @@ export const BLONDIE: ArtistWithAlbums = {
{ id: uuid(), name: "Blondie" }, { id: uuid(), name: "Blondie" },
{ id: uuid(), name: "Parallel Lines" }, { id: uuid(), name: "Parallel Lines" },
], ],
image: {
small: undefined,
medium: undefined,
large: undefined,
}
}; };
export const MADONNA: ArtistWithAlbums = { export const MADONNA: ArtistWithAlbums = {
id: uuid(), id: uuid(),
name: "Madonna", name: "Madonna",
albums: [], albums: [],
image: {
small: "http://localhost/MADONNA/sml",
medium: undefined,
large: "http://localhost/MADONNA/lge",
}
}; };
export const METALLICA: ArtistWithAlbums = { export const METALLICA: ArtistWithAlbums = {
@@ -110,6 +125,11 @@ export const METALLICA: ArtistWithAlbums = {
name: "Master of Puppets", name: "Master of Puppets",
}, },
], ],
image: {
small: "http://localhost/METALLICA/sml",
medium: "http://localhost/METALLICA/med",
large: "http://localhost/METALLICA/lge",
}
}; };
export const ALL_ALBUMS = [ export const ALL_ALBUMS = [

View File

@@ -1,4 +1,4 @@
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService, artistWithAlbumsToArtist } from "./in_memory_music_service";
import { AuthSuccess, MusicLibrary } from "../src/music_service"; import { AuthSuccess, MusicLibrary } from "../src/music_service";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { import {
@@ -43,6 +43,16 @@ describe("InMemoryMusicService", () => {
}); });
}); });
describe("artistWithAlbumsToArtist", () => {
it("should map fields correctly", () => {
expect(artistWithAlbumsToArtist(BOB_MARLEY)).toEqual({
id: BOB_MARLEY.id,
name: BOB_MARLEY.name,
image: BOB_MARLEY.image
})
});
});
describe("Music Library", () => { describe("Music Library", () => {
const user = { username: "user100", password: "password100" }; const user = { username: "user100", password: "password100" };
let musicLibrary: MusicLibrary; let musicLibrary: MusicLibrary;
@@ -61,10 +71,10 @@ describe("InMemoryMusicService", () => {
describe("fetching all", () => { describe("fetching all", () => {
it("should provide an array of artists", async () => { it("should provide an array of artists", async () => {
const artists = [ const artists = [
{ id: BOB_MARLEY.id, name: BOB_MARLEY.name }, artistWithAlbumsToArtist(BOB_MARLEY),
{ id: MADONNA.id, name: MADONNA.name }, artistWithAlbumsToArtist(MADONNA),
{ id: BLONDIE.id, name: BLONDIE.name }, artistWithAlbumsToArtist(BLONDIE),
{ id: METALLICA.id, name: METALLICA.name }, artistWithAlbumsToArtist(METALLICA),
]; ];
expect(await musicLibrary.artists({ _index: 0, _count: 100 })).toEqual({ expect(await musicLibrary.artists({ _index: 0, _count: 100 })).toEqual({
results: artists, results: artists,
@@ -76,8 +86,8 @@ describe("InMemoryMusicService", () => {
describe("fetching the second page", () => { describe("fetching the second page", () => {
it("should provide an array of artists", async () => { it("should provide an array of artists", async () => {
const artists = [ const artists = [
{ id: BLONDIE.id, name: BLONDIE.name }, artistWithAlbumsToArtist(BLONDIE),
{ id: METALLICA.id, name: METALLICA.name }, artistWithAlbumsToArtist(METALLICA),
]; ];
expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({ expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({
results: artists, results: artists,
@@ -89,9 +99,9 @@ describe("InMemoryMusicService", () => {
describe("fetching the more items than fit on the second page", () => { describe("fetching the more items than fit on the second page", () => {
it("should provide an array of artists", async () => { it("should provide an array of artists", async () => {
const artists = [ const artists = [
{ id: MADONNA.id, name: MADONNA.name }, artistWithAlbumsToArtist(MADONNA),
{ id: BLONDIE.id, name: BLONDIE.name }, artistWithAlbumsToArtist(BLONDIE),
{ id: METALLICA.id, name: METALLICA.name }, artistWithAlbumsToArtist(METALLICA),
]; ];
expect( expect(
await musicLibrary.artists({ _index: 1, _count: 50 }) await musicLibrary.artists({ _index: 1, _count: 50 })
@@ -103,14 +113,8 @@ describe("InMemoryMusicService", () => {
describe("artist", () => { describe("artist", () => {
describe("when it exists", () => { describe("when it exists", () => {
it("should provide an artist", () => { it("should provide an artist", () => {
expect(musicLibrary.artist(MADONNA.id)).toEqual({ expect(musicLibrary.artist(MADONNA.id)).toEqual(artistWithAlbumsToArtist(MADONNA));
id: MADONNA.id, expect(musicLibrary.artist(BLONDIE.id)).toEqual(artistWithAlbumsToArtist(BLONDIE));
name: MADONNA.name,
});
expect(musicLibrary.artist(BLONDIE.id)).toEqual({
id: BLONDIE.id,
name: BLONDIE.name,
});
}); });
}); });

View File

@@ -15,9 +15,10 @@ import {
asResult, asResult,
} from "../src/music_service"; } from "../src/music_service";
const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({ export const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({
id: it.id, id: it.id,
name: it.name, name: it.name,
image: it.image
}); });
const getOrThrow = (message: string) => const getOrThrow = (message: string) =>

View File

@@ -1,6 +1,6 @@
import { Md5 } from "ts-md5/dist/md5"; import { Md5 } from "ts-md5/dist/md5";
import { Navidrome, t } from "../src/navidrome"; import { Navidrome, t, artistInfo } from "../src/navidrome";
import encryption from "../src/encryption"; import encryption from "../src/encryption";
import axios from "axios"; import axios from "axios";
@@ -18,6 +18,28 @@ describe("t", () => {
}); });
}); });
const ok = (data: string) => ({
status: 200,
data,
});
const artistInfoXml = (
artistInfo: Partial<artistInfo>
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artistInfo>
<biography></biography>
<musicBrainzId></musicBrainzId>
<lastFmUrl></lastFmUrl>
<smallImageUrl>${artistInfo.smallImageUrl || ""}</smallImageUrl>
<mediumImageUrl>${
artistInfo.mediumImageUrl || ""
}</mediumImageUrl>
<largeImageUrl>${artistInfo.largeImageUrl || ""}</largeImageUrl>
</artistInfo>
</subsonic-response>`;
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
describe("navidrome", () => { describe("navidrome", () => {
const url = "http://127.0.0.22:4567"; const url = "http://127.0.0.22:4567";
const username = "user1"; const username = "user1";
@@ -27,11 +49,12 @@ describe("navidrome", () => {
const navidrome = new Navidrome(url, encryption("secret")); const navidrome = new Navidrome(url, encryption("secret"));
const mockedRandomString = (randomString as unknown) as jest.Mock; const mockedRandomString = (randomString as unknown) as jest.Mock;
const mockGET = jest.fn()
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
axios.get = jest.fn(); axios.get = mockGET;
mockedRandomString.mockReturnValue(salt); mockedRandomString.mockReturnValue(salt);
}); });
@@ -47,13 +70,12 @@ describe("navidrome", () => {
describe("generateToken", () => { describe("generateToken", () => {
describe("when the credentials are valid", () => { describe("when the credentials are valid", () => {
it("should be able to generate a token and then login using it", async () => { it("should be able to generate a token and then login using it", async () => {
(axios.get as jest.Mock).mockResolvedValue({ (axios.get as jest.Mock).mockResolvedValue(ok(PING_OK));
status: 200,
data: `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
</subsonic-response>`,
});
const token = await navidrome.generateToken({ username, password }) as AuthSuccess; const token = (await navidrome.generateToken({
username,
password,
})) as AuthSuccess;
expect(token.authToken).toBeDefined(); expect(token.authToken).toBeDefined();
expect(token.nickname).toEqual(username); expect(token.nickname).toEqual(username);
@@ -75,51 +97,117 @@ describe("navidrome", () => {
}); });
const token = await navidrome.generateToken({ username, password }); const token = await navidrome.generateToken({ username, password });
expect(token).toEqual({ message: "Wrong username or password" }) expect(token).toEqual({ message: "Wrong username or password" });
}); });
}); });
}); });
describe("getArtists", () => { describe("getArtists", () => {
const getArtistsXml = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artists lastModified="1614586749000" ignoredArticles="The El La Los Las Le Les Os As O A">
<index name="#">
<artist id="2911b2d67a6b11eb804dd360a6225680" name="artist1" albumCount="22"></artist>
<artist id="3c0b9d7a7a6b11eb9773f398e6236ad6" name="artist2" albumCount="9"></artist>
</index>
<index name="A">
<artist id="3c5113007a6b11eb87173bfb9b07f9b1" name="artist3" albumCount="2"></artist>
</index>
<index name="B">
<artist id="3ca781c27a6b11eb897ebbb5773603ad" name="artist4" albumCount="2"></artist>
</index>
</artists>
</subsonic-response>`;
const artist1_getArtistInfoXml = artistInfoXml({
smallImageUrl: "sml1",
mediumImageUrl: "med1",
largeImageUrl: "lge1",
});
const artist2_getArtistInfoXml = artistInfoXml({
smallImageUrl: "sml2",
mediumImageUrl: undefined,
largeImageUrl: "lge2",
});
const artist3_getArtistInfoXml = artistInfoXml({
smallImageUrl: undefined,
mediumImageUrl: "med3",
largeImageUrl: undefined,
});
const artist4_getArtistInfoXml = artistInfoXml({
smallImageUrl: "sml4",
mediumImageUrl: "med4",
largeImageUrl: "lge4",
});
beforeEach(() => { beforeEach(() => {
(axios.get as jest.Mock).mockResolvedValue({ mockGET
status: 200, .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
data: `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml)))
<artists lastModified="1614586749000" ignoredArticles="The El La Los Las Le Les Os As O A"> .mockImplementationOnce(() => Promise.resolve(ok(artist1_getArtistInfoXml)))
<index name="#"> .mockImplementationOnce(() => Promise.resolve(ok(artist2_getArtistInfoXml)))
<artist id="2911b2d67a6b11eb804dd360a6225680" name="10 Planets" albumCount="22"></artist> .mockImplementationOnce(() => Promise.resolve(ok(artist3_getArtistInfoXml)))
<artist id="3c0b9d7a7a6b11eb9773f398e6236ad6" name="1200 Ounces" albumCount="9"></artist> .mockImplementationOnce(() => Promise.resolve(ok(artist4_getArtistInfoXml)));
</index>
<index name="A">
<artist id="3c5113007a6b11eb87173bfb9b07f9b1" name="AAAB" albumCount="2"></artist>
</index>
<index name="B">
<artist id="3ca781c27a6b11eb897ebbb5773603ad" name="BAAB" albumCount="2"></artist>
</index>
</artists>
</subsonic-response>`,
});
}); });
describe("when no paging is in effect", () => { describe("when no paging is in effect", () => {
it("should return all the artists", async () => { it("should return all the artists", async () => {
const artists = await navidrome const artists = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then(it => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.artists({ _index: 0, _count: 100 })); .then((it) => it.artists({ _index: 0, _count: 100 }));
const expectedArtists = [ const expectedArtists = [
{ id: "2911b2d67a6b11eb804dd360a6225680", name: "10 Planets" }, {
{ id: "3c0b9d7a7a6b11eb9773f398e6236ad6", name: "1200 Ounces" }, id: "2911b2d67a6b11eb804dd360a6225680",
{ id: "3c5113007a6b11eb87173bfb9b07f9b1", name: "AAAB" }, name: "artist1",
{ id: "3ca781c27a6b11eb897ebbb5773603ad", name: "BAAB" }, image: { small: "sml1", medium: "med1", large: "lge1" },
},
{
id: "3c0b9d7a7a6b11eb9773f398e6236ad6",
name: "artist2",
image: { small: "sml2", medium: "", large: "lge2" },
},
{
id: "3c5113007a6b11eb87173bfb9b07f9b1",
name: "artist3",
image: { small: "", medium: "med3", large: "" },
},
{
id: "3ca781c27a6b11eb897ebbb5773603ad",
name: "artist4",
image: { small: "sml4", medium: "med4", large: "lge4" },
},
]; ];
expect(artists).toEqual({ results: expectedArtists, total: 4 }); expect(artists).toEqual({ results: expectedArtists, total: 4 });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
params: authParams, params: authParams,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "2911b2d67a6b11eb804dd360a6225680",
...authParams,
},
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "3c0b9d7a7a6b11eb9773f398e6236ad6",
...authParams,
},
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "3c5113007a6b11eb87173bfb9b07f9b1",
...authParams,
},
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "3ca781c27a6b11eb897ebbb5773603ad",
...authParams,
},
});
}); });
}); });
@@ -127,19 +215,51 @@ describe("navidrome", () => {
it("should return only the correct page of artists", async () => { it("should return only the correct page of artists", async () => {
const artists = await navidrome const artists = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then(it => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.artists({ _index: 1, _count: 2 })); .then((it) => it.artists({ _index: 1, _count: 2 }));
const expectedArtists = [ const expectedArtists = [
{ id: "3c0b9d7a7a6b11eb9773f398e6236ad6", name: "1200 Ounces" }, {
{ id: "3c5113007a6b11eb87173bfb9b07f9b1", name: "AAAB" }, id: "3c0b9d7a7a6b11eb9773f398e6236ad6",
]; name: "artist2",
expect(artists).toEqual({ results: expectedArtists, total: 4 }); image: { small: "sml2", medium: "", large: "lge2" },
},
{
id: "3c5113007a6b11eb87173bfb9b07f9b1",
name: "artist3",
image: { small: "", medium: "med3", large: "" },
},
];
expect(artists).toEqual({ results: expectedArtists, total: 4 });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, {
params: authParams, params: authParams,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "2911b2d67a6b11eb804dd360a6225680",
...authParams,
},
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "3c0b9d7a7a6b11eb9773f398e6236ad6",
...authParams,
},
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "3c5113007a6b11eb87173bfb9b07f9b1",
...authParams,
},
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: "3ca781c27a6b11eb897ebbb5773603ad",
...authParams,
},
});
}); });
}); });
}); });

View File

@@ -377,9 +377,15 @@ describe("api", () => {
}); });
expect(artists[0]).toEqual( expect(artists[0]).toEqual(
getMetadataResult({ getMetadataResult({
mediaCollection: [BLONDIE, BOB_MARLEY].map((it) => container({ id: `artist:${it.id}`, title: it.name })), mediaCollection: [BLONDIE, BOB_MARLEY].map((it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: it.image.small,
})),
index: 0, index: 0,
total: 2 total: 2,
}) })
); );
}); });
@@ -403,7 +409,7 @@ describe("api", () => {
container({ id: `album:${it.id}`, title: it.name }) container({ id: `album:${it.id}`, title: it.name })
), ),
index: 0, index: 0,
total: BLONDIE.albums.length + BOB_MARLEY.albums.length total: BLONDIE.albums.length + BOB_MARLEY.albums.length,
}) })
); );
}); });
@@ -434,7 +440,7 @@ describe("api", () => {
}), }),
], ],
index: 2, index: 2,
total: BLONDIE.albums.length + BOB_MARLEY.albums.length total: BLONDIE.albums.length + BOB_MARLEY.albums.length,
}) })
); );
}); });