Album art displaying for artists

This commit is contained in:
simojenki
2021-03-12 15:16:44 +11:00
parent f38e4cab88
commit 3d1e8a48c9
8 changed files with 339 additions and 93 deletions

View File

@@ -113,7 +113,8 @@ export class InMemoryMusicService implements MusicService {
stream: (_: {
trackId: string;
range: string | undefined;
}) => Promise.reject("unsupported operation")
}) => Promise.reject("unsupported operation"),
coverArt: (id: string, size?: number) => Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`)
});
}

View File

@@ -825,7 +825,7 @@ describe("Navidrome", () => {
"content-length": "1667",
"content-range": "-200",
"accept-ranges": "bytes",
"some-other-header": "some-value"
"some-other-header": "some-value",
},
data: Buffer.from("the track", "ascii"),
};
@@ -844,7 +844,7 @@ describe("Navidrome", () => {
"content-type": "audio/mpeg",
"content-length": "1667",
"content-range": "-200",
"accept-ranges": "bytes"
"accept-ranges": "bytes",
});
expect(result.data.toString()).toEqual("the track");
@@ -895,7 +895,7 @@ describe("Navidrome", () => {
"content-length": "66",
"content-range": "100-200",
"accept-ranges": "none",
"some-other-header": "some-value"
"some-other-header": "some-value",
},
data: Buffer.from("the track", "ascii"),
};
@@ -914,7 +914,7 @@ describe("Navidrome", () => {
"content-type": "audio/flac",
"content-length": "66",
"content-range": "100-200",
"accept-ranges": "none"
"accept-ranges": "none",
});
expect(result.data.toString()).toEqual("the track");
@@ -932,4 +932,84 @@ describe("Navidrome", () => {
});
});
});
describe("fetching cover art", () => {
describe("fetching album art", () => {
describe("when no size is specified", async () => {
it("should fetch the image", async () => {
const streamResponse = {
status: 200,
headers: {
"content-type": "image/jpeg",
},
data: Buffer.from("the image", "ascii"),
};
const coverArtId = "someCoverArt";
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(coverArtId));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
data: streamResponse.data,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, {
params: {
id: coverArtId,
...authParams,
},
headers,
responseType: "arraybuffer",
});
});
});
describe("when size is specified", async () => {
it("should fetch the image", async () => {
const streamResponse = {
status: 200,
headers: {
"content-type": "image/jpeg",
},
data: Buffer.from("the image", "ascii"),
};
const coverArtId = "someCoverArt";
const size = 1879;
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(coverArtId, size));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
data: streamResponse.data,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, {
params: {
id: coverArtId,
size,
...authParams,
},
headers,
responseType: "arraybuffer",
});
});
});
});
});
});

View File

@@ -1,11 +1,14 @@
import { v4 as uuid } from "uuid";
import dayjs from "dayjs";
import request from "supertest";
import { MusicService } from "../src/music_service";
import makeServer from "../src/server";
import makeServer, { BONOB_ACCESS_TOKEN_HEADER } from "../src/server";
import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
import { aDevice, aService } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import { ExpiringAccessTokens } from "../src/access_tokens";
import { InMemoryLinkCodes } from "../src/link_codes";
describe("server", () => {
beforeEach(() => {
@@ -195,15 +198,45 @@ describe("server", () => {
const musicLibrary = {
stream: jest.fn(),
};
let now = dayjs();
const accessTokens = new ExpiringAccessTokens({ now: () => now });
const server = makeServer(
(jest.fn() as unknown) as Sonos,
aService(),
"http://localhost:1234",
(musicService as unknown) as MusicService
(musicService as unknown) as MusicService,
new InMemoryLinkCodes(),
accessTokens
);
const authToken = uuid();
const trackId = uuid();
let accessToken: string;
beforeEach(() => {
accessToken = accessTokens.mint(authToken);
});
describe("when there is no access-token", () => {
it("should return a 401", async () => {
const res = await request(server).get(`/stream/track/${trackId}`);
expect(res.status).toEqual(401);
});
});
describe("when the access-token has expired", () => {
it("should return a 401", async () => {
now = now.add(1, "day");
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(401);
});
});
describe("when sonos does not ask for a range", () => {
describe("when the music service returns a 200", () => {
@@ -224,9 +257,7 @@ describe("server", () => {
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set("bonob-token", authToken);
console.log("testing finished watiting");
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual(
@@ -262,9 +293,7 @@ describe("server", () => {
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set("bonob-token", authToken);
console.log("testing finished watiting");
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual(
@@ -303,11 +332,9 @@ describe("server", () => {
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set("bonob-token", authToken)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", "3000-4000");
console.log("testing finished watiting");
expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual(
stream.headers["content-type"]
@@ -345,11 +372,9 @@ describe("server", () => {
const res = await request(server)
.get(`/stream/track/${trackId}`)
.set("bonob-token", authToken)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", "4000-5000");
console.log("testing finished watiting");
expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual(
stream.headers["content-type"]
@@ -370,4 +395,79 @@ describe("server", () => {
});
});
});
describe("/album/:albumId/art", () => {
const musicService = {
login: jest.fn(),
};
const musicLibrary = {
coverArt: jest.fn(),
};
let now = dayjs();
const accessTokens = new ExpiringAccessTokens({ now: () => now });
const server = makeServer(
(jest.fn() as unknown) as Sonos,
aService(),
"http://localhost:1234",
(musicService as unknown) as MusicService,
new InMemoryLinkCodes(),
accessTokens
);
const authToken = uuid();
const albumId = uuid();
let accessToken: string;
beforeEach(() => {
accessToken = accessTokens.mint(authToken);
});
describe("when there is no access-token", () => {
it("should return a 401", async () => {
const res = await request(server).get(`/album/123/art`);
expect(res.status).toEqual(401);
});
});
describe("when the access-token has expired", () => {
it("should return a 401", async () => {
now = now.add(1, "day");
const res = await request(server).get(
`/album/123/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
);
expect(res.status).toEqual(401);
});
});
describe("when there is a valid access token", () => {
describe("when the image exists in the music service", () => {
it("should return the image and a 200", async () => {
const coverArt = {
status: 200,
contentType: "image/jpeg",
data: Buffer.from("some image", "ascii"),
};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.coverArt.mockResolvedValue(coverArt);
const res = await request(server)
.get(
`/album/${albumId}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(coverArt.status);
expect(res.header["content-type"]).toEqual(coverArt.contentType);
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.coverArt).toHaveBeenCalledWith(albumId, 200);
});
});
});
});
});

View File

@@ -5,7 +5,7 @@ import X2JS from "x2js";
import { v4 as uuid } from "uuid";
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
import makeServer from "../src/server";
import makeServer, { BONOB_ACCESS_TOKEN_HEADER } from "../src/server";
import { bonobService, SONOS_DISABLED } from "../src/sonos";
import {
STRINGS_ROUTE,
@@ -25,6 +25,7 @@ import {
import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap";
import { AuthSuccess } from "../src/music_service";
import { AccessTokens } from "../src/access_tokens";
describe("service config", () => {
describe("strings.xml", () => {
@@ -84,11 +85,21 @@ describe("getMetadataResult", () => {
});
});
class Base64AccessTokens implements AccessTokens {
mint(authToken: string) {
return Buffer.from(authToken).toString("base64");
}
authTokenFor(value: string) {
return Buffer.from(value, "base64").toString("ascii");
}
}
describe("api", () => {
const rootUrl = "http://localhost:1234";
const service = bonobService("test-api", 133, rootUrl, "AppLink");
const musicService = new InMemoryMusicService();
const linkCodes = new InMemoryLinkCodes();
const accessTokens = new Base64AccessTokens();
beforeEach(() => {
musicService.clear();
@@ -101,7 +112,8 @@ describe("api", () => {
service,
rootUrl,
musicService,
linkCodes
linkCodes,
accessTokens
);
describe(LOGIN_ROUTE, () => {
@@ -174,7 +186,8 @@ describe("api", () => {
service,
rootUrl,
musicService,
(mockLinkCodes as unknown) as LinkCodes
(mockLinkCodes as unknown) as LinkCodes,
accessTokens
);
it("should do something", async () => {
@@ -211,7 +224,8 @@ describe("api", () => {
service,
rootUrl,
musicService,
linkCodes
linkCodes,
accessTokens
);
describe("when there is a linkCode association", () => {
@@ -278,7 +292,8 @@ describe("api", () => {
service,
rootUrl,
musicService,
linkCodes
linkCodes,
accessTokens
);
describe("when no credentials header provided", () => {
@@ -452,6 +467,7 @@ describe("api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
})),
index: 0,
total: artistWithManyAlbums.albums.length,
@@ -476,6 +492,7 @@ describe("api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
})),
index: 2,
total: artistWithManyAlbums.albums.length,
@@ -605,6 +622,7 @@ describe("api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
})),
index: 0,
total: 6,
@@ -630,6 +648,7 @@ describe("api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
})),
index: 2,
total: 6,
@@ -736,12 +755,17 @@ describe("api", () => {
});
describe("getMediaURI", () => {
const accessTokenMint = jest.fn();
const server = makeServer(
SONOS_DISABLED,
service,
rootUrl,
musicService,
linkCodes
linkCodes,
({
mint: accessTokenMint,
} as unknown) as AccessTokens
);
describe("when no credentials header provided", () => {
@@ -797,6 +821,7 @@ describe("api", () => {
const password = "validPassword";
let token: AuthSuccess;
let ws: Client;
const accessToken = "temporaryAccessToken";
beforeEach(async () => {
musicService.hasUser({ username, password });
@@ -809,6 +834,8 @@ describe("api", () => {
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(token.authToken) });
accessTokenMint.mockReturnValue(accessToken);
});
describe("asking for a URI to stream a track", () => {
@@ -821,8 +848,8 @@ describe("api", () => {
expect(root[0]).toEqual({
getMediaURIResult: `${rootUrl}/stream/track/${trackId}`,
httpHeaders: {
header: "bonob-token",
value: token.authToken,
header: BONOB_ACCESS_TOKEN_HEADER,
value: accessToken,
},
});
});
@@ -836,7 +863,8 @@ describe("api", () => {
service,
rootUrl,
musicService,
linkCodes
linkCodes,
accessTokens
);
describe("when no credentials header provided", () => {