mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Album art displaying for artists
This commit is contained in:
@@ -113,6 +113,11 @@ export type Stream = {
|
|||||||
data: Buffer;
|
data: Buffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CoverArt = {
|
||||||
|
contentType: string;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
export const range = (size: number) => [...Array(size).keys()];
|
export const range = (size: number) => [...Array(size).keys()];
|
||||||
|
|
||||||
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
||||||
@@ -140,4 +145,5 @@ export interface MusicLibrary {
|
|||||||
trackId: string;
|
trackId: string;
|
||||||
range: string | undefined;
|
range: string | undefined;
|
||||||
}): Promise<Stream>;
|
}): Promise<Stream>;
|
||||||
|
coverArt(id: string, size?: number): Promise<CoverArt>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -450,6 +450,21 @@ export class Navidrome implements MusicService {
|
|||||||
},
|
},
|
||||||
data: Buffer.from(res.data, "binary"),
|
data: Buffer.from(res.data, "binary"),
|
||||||
})),
|
})),
|
||||||
|
coverArt: async (id: string, size?: number) =>
|
||||||
|
navidrome
|
||||||
|
.get(
|
||||||
|
credentials,
|
||||||
|
"/rest/getCoverArt",
|
||||||
|
{ id, size },
|
||||||
|
{
|
||||||
|
headers: { "User-Agent": "bonob" },
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => ({
|
||||||
|
contentType: res.headers["content-type"],
|
||||||
|
data: Buffer.from(res.data, "binary"),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.resolve(musicLibrary);
|
return Promise.resolve(musicLibrary);
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import {
|
|||||||
} from "./smapi";
|
} from "./smapi";
|
||||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||||
import { MusicService, isSuccess } from "./music_service";
|
import { MusicService, isSuccess } from "./music_service";
|
||||||
// import logger from "./logger";
|
|
||||||
import bindSmapiSoapServiceToExpress from "./smapi";
|
import bindSmapiSoapServiceToExpress from "./smapi";
|
||||||
|
import { AccessTokens, ExpiringAccessTokens } from "./access_tokens";
|
||||||
|
|
||||||
|
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||||
|
|
||||||
function server(
|
function server(
|
||||||
sonos: Sonos,
|
sonos: Sonos,
|
||||||
bonobService: Service,
|
bonobService: Service,
|
||||||
webAddress: string | "http://localhost:4534",
|
webAddress: string | "http://localhost:4534",
|
||||||
musicService: MusicService,
|
musicService: MusicService,
|
||||||
linkCodes: LinkCodes = new InMemoryLinkCodes()
|
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
||||||
|
accessTokens: AccessTokens = new ExpiringAccessTokens()
|
||||||
): Express {
|
): Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -115,9 +118,13 @@ function server(
|
|||||||
|
|
||||||
app.get("/stream/track/:id", async (req, res) => {
|
app.get("/stream/track/:id", async (req, res) => {
|
||||||
const id = req.params["id"]!;
|
const id = req.params["id"]!;
|
||||||
const token = req.headers["bonob-token"] as string;
|
const accessToken = req.headers[BONOB_ACCESS_TOKEN_HEADER] as string;
|
||||||
|
const authToken = accessTokens.authTokenFor(accessToken);
|
||||||
|
if (!authToken) {
|
||||||
|
return res.status(401).send();
|
||||||
|
} else {
|
||||||
return musicService
|
return musicService
|
||||||
.login(token)
|
.login(authToken)
|
||||||
.then((it) =>
|
.then((it) =>
|
||||||
it.stream({ trackId: id, range: req.headers["range"] || undefined })
|
it.stream({ trackId: id, range: req.headers["range"] || undefined })
|
||||||
)
|
)
|
||||||
@@ -128,34 +135,34 @@ function server(
|
|||||||
);
|
);
|
||||||
res.send(stream.data);
|
res.send(stream.data);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// app.get("/album/:albumId/art", (req, res) => {
|
app.get("/album/:albumId/art", (req, res) => {
|
||||||
// console.log(`Trying to load image for ${req.params["albumId"]}, token ${JSON.stringify(req.cookies)}`)
|
const authToken = accessTokens.authTokenFor(
|
||||||
// const authToken = req.headers["X-AuthToken"]! as string;
|
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
|
||||||
// const albumId = req.params["albumId"]!;
|
);
|
||||||
// musicService
|
if (!authToken) {
|
||||||
// .login(authToken)
|
return res.status(401).send();
|
||||||
// // .then((it) => it.artist(artistId))
|
} else {
|
||||||
// // .then(artist => artist.image.small)
|
return musicService
|
||||||
// .then((url) => {
|
.login(authToken)
|
||||||
// if (url) {
|
.then((it) => it.coverArt(req.params["albumId"]!, 200))
|
||||||
// console.log(`${albumId} sending 307 -> ${url}`)
|
.then((coverArt) => {
|
||||||
// // res.setHeader("Location", url);
|
res.status(200);
|
||||||
// res.status(307).send();
|
res.setHeader("content-type", coverArt.contentType);
|
||||||
// } else {
|
res.send(coverArt.data);
|
||||||
// console.log(`${albumId} sending 404`)
|
});
|
||||||
// res.status(404).send();
|
}
|
||||||
// }
|
});
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
bindSmapiSoapServiceToExpress(
|
bindSmapiSoapServiceToExpress(
|
||||||
app,
|
app,
|
||||||
SOAP_PATH,
|
SOAP_PATH,
|
||||||
webAddress,
|
webAddress,
|
||||||
linkCodes,
|
linkCodes,
|
||||||
musicService
|
musicService,
|
||||||
|
accessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
55
src/smapi.ts
55
src/smapi.ts
@@ -13,6 +13,8 @@ import {
|
|||||||
slice2,
|
slice2,
|
||||||
Track,
|
Track,
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
|
import { AccessTokens } from "./access_tokens";
|
||||||
|
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
|
||||||
|
|
||||||
export const LOGIN_ROUTE = "/login";
|
export const LOGIN_ROUTE = "/login";
|
||||||
export const SOAP_PATH = "/ws/sonos";
|
export const SOAP_PATH = "/ws/sonos";
|
||||||
@@ -193,16 +195,15 @@ const genre = (genre: string) => ({
|
|||||||
title: genre,
|
title: genre,
|
||||||
});
|
});
|
||||||
|
|
||||||
const album = (album: AlbumSummary) => ({
|
const album = (
|
||||||
|
webAddress: string,
|
||||||
|
accessToken: string,
|
||||||
|
album: AlbumSummary
|
||||||
|
) => ({
|
||||||
itemType: "album",
|
itemType: "album",
|
||||||
id: `album:${album.id}`,
|
id: `album:${album.id}`,
|
||||||
title: album.name,
|
title: album.name,
|
||||||
// albumArtURI: {
|
albumArtURI: `${webAddress}/album/${album.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`,
|
||||||
// attributes: {
|
|
||||||
// requiresAuthentication: "true"
|
|
||||||
// },
|
|
||||||
// $value: `${webAddress}/album/${album.id}/art`
|
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const track = (track: Track) => ({
|
const track = (track: Track) => ({
|
||||||
@@ -235,7 +236,8 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
soapPath: string,
|
soapPath: string,
|
||||||
webAddress: string,
|
webAddress: string,
|
||||||
linkCodes: LinkCodes,
|
linkCodes: LinkCodes,
|
||||||
musicService: MusicService
|
musicService: MusicService,
|
||||||
|
accessTokens: AccessTokens
|
||||||
) {
|
) {
|
||||||
const sonosSoap = new SonosSoap(webAddress, linkCodes);
|
const sonosSoap = new SonosSoap(webAddress, linkCodes);
|
||||||
const soapyService = listen(
|
const soapyService = listen(
|
||||||
@@ -276,8 +278,10 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`,
|
getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`,
|
||||||
httpHeaders: [
|
httpHeaders: [
|
||||||
{
|
{
|
||||||
header: "bonob-token",
|
header: BONOB_ACCESS_TOKEN_HEADER,
|
||||||
value: headers?.credentials?.loginToken.token,
|
value: accessTokens.mint(
|
||||||
|
headers?.credentials?.loginToken.token
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -330,9 +334,8 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const login = await musicService
|
const authToken = headers.credentials.loginToken.token;
|
||||||
.login(headers.credentials.loginToken.token)
|
const login = await musicService.login(authToken).catch((_) => {
|
||||||
.catch((_) => {
|
|
||||||
throw {
|
throw {
|
||||||
Fault: {
|
Fault: {
|
||||||
faultcode: "Client.LoginUnauthorized",
|
faultcode: "Client.LoginUnauthorized",
|
||||||
@@ -372,13 +375,16 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
case "albums":
|
case "albums":
|
||||||
return await musicLibrary.albums(paging).then((result) =>
|
return await musicLibrary.albums(paging).then((result) => {
|
||||||
getMetadataResult({
|
const accessToken = accessTokens.mint(authToken);
|
||||||
mediaCollection: result.results.map(album),
|
return getMetadataResult({
|
||||||
|
mediaCollection: result.results.map((it) =>
|
||||||
|
album(webAddress, accessToken, it)
|
||||||
|
),
|
||||||
index: paging._index,
|
index: paging._index,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
case "genres":
|
case "genres":
|
||||||
return await musicLibrary
|
return await musicLibrary
|
||||||
.genres()
|
.genres()
|
||||||
@@ -395,13 +401,16 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
.artist(typeId!)
|
.artist(typeId!)
|
||||||
.then((artist) => artist.albums)
|
.then((artist) => artist.albums)
|
||||||
.then(slice2(paging))
|
.then(slice2(paging))
|
||||||
.then(([page, total]) =>
|
.then(([page, total]) => {
|
||||||
getMetadataResult({
|
const accessToken = accessTokens.mint(authToken);
|
||||||
mediaCollection: page.map(album),
|
return getMetadataResult({
|
||||||
|
mediaCollection: page.map((it) =>
|
||||||
|
album(webAddress, accessToken, it)
|
||||||
|
),
|
||||||
index: paging._index,
|
index: paging._index,
|
||||||
total,
|
total,
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
case "album":
|
case "album":
|
||||||
return await musicLibrary
|
return await musicLibrary
|
||||||
.tracks(typeId!)
|
.tracks(typeId!)
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
stream: (_: {
|
stream: (_: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
range: string | undefined;
|
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}`)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -825,7 +825,7 @@ describe("Navidrome", () => {
|
|||||||
"content-length": "1667",
|
"content-length": "1667",
|
||||||
"content-range": "-200",
|
"content-range": "-200",
|
||||||
"accept-ranges": "bytes",
|
"accept-ranges": "bytes",
|
||||||
"some-other-header": "some-value"
|
"some-other-header": "some-value",
|
||||||
},
|
},
|
||||||
data: Buffer.from("the track", "ascii"),
|
data: Buffer.from("the track", "ascii"),
|
||||||
};
|
};
|
||||||
@@ -844,7 +844,7 @@ describe("Navidrome", () => {
|
|||||||
"content-type": "audio/mpeg",
|
"content-type": "audio/mpeg",
|
||||||
"content-length": "1667",
|
"content-length": "1667",
|
||||||
"content-range": "-200",
|
"content-range": "-200",
|
||||||
"accept-ranges": "bytes"
|
"accept-ranges": "bytes",
|
||||||
});
|
});
|
||||||
expect(result.data.toString()).toEqual("the track");
|
expect(result.data.toString()).toEqual("the track");
|
||||||
|
|
||||||
@@ -895,7 +895,7 @@ describe("Navidrome", () => {
|
|||||||
"content-length": "66",
|
"content-length": "66",
|
||||||
"content-range": "100-200",
|
"content-range": "100-200",
|
||||||
"accept-ranges": "none",
|
"accept-ranges": "none",
|
||||||
"some-other-header": "some-value"
|
"some-other-header": "some-value",
|
||||||
},
|
},
|
||||||
data: Buffer.from("the track", "ascii"),
|
data: Buffer.from("the track", "ascii"),
|
||||||
};
|
};
|
||||||
@@ -914,7 +914,7 @@ describe("Navidrome", () => {
|
|||||||
"content-type": "audio/flac",
|
"content-type": "audio/flac",
|
||||||
"content-length": "66",
|
"content-length": "66",
|
||||||
"content-range": "100-200",
|
"content-range": "100-200",
|
||||||
"accept-ranges": "none"
|
"accept-ranges": "none",
|
||||||
});
|
});
|
||||||
expect(result.data.toString()).toEqual("the track");
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { MusicService } from "../src/music_service";
|
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 { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
|
||||||
|
|
||||||
import { aDevice, aService } from "./builders";
|
import { aDevice, aService } from "./builders";
|
||||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||||
|
import { ExpiringAccessTokens } from "../src/access_tokens";
|
||||||
|
import { InMemoryLinkCodes } from "../src/link_codes";
|
||||||
|
|
||||||
describe("server", () => {
|
describe("server", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -195,15 +198,45 @@ describe("server", () => {
|
|||||||
const musicLibrary = {
|
const musicLibrary = {
|
||||||
stream: jest.fn(),
|
stream: jest.fn(),
|
||||||
};
|
};
|
||||||
|
let now = dayjs();
|
||||||
|
const accessTokens = new ExpiringAccessTokens({ now: () => now });
|
||||||
|
|
||||||
const server = makeServer(
|
const server = makeServer(
|
||||||
(jest.fn() as unknown) as Sonos,
|
(jest.fn() as unknown) as Sonos,
|
||||||
aService(),
|
aService(),
|
||||||
"http://localhost:1234",
|
"http://localhost:1234",
|
||||||
(musicService as unknown) as MusicService
|
(musicService as unknown) as MusicService,
|
||||||
|
new InMemoryLinkCodes(),
|
||||||
|
accessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
const authToken = uuid();
|
const authToken = uuid();
|
||||||
const trackId = 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 sonos does not ask for a range", () => {
|
||||||
describe("when the music service returns a 200", () => {
|
describe("when the music service returns a 200", () => {
|
||||||
@@ -224,9 +257,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set("bonob-token", authToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
console.log("testing finished watiting");
|
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(stream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
@@ -262,9 +293,7 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set("bonob-token", authToken);
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||||
|
|
||||||
console.log("testing finished watiting");
|
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(stream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
@@ -303,11 +332,9 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set("bonob-token", authToken)
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
||||||
.set("Range", "3000-4000");
|
.set("Range", "3000-4000");
|
||||||
|
|
||||||
console.log("testing finished watiting");
|
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(stream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
stream.headers["content-type"]
|
stream.headers["content-type"]
|
||||||
@@ -345,11 +372,9 @@ describe("server", () => {
|
|||||||
|
|
||||||
const res = await request(server)
|
const res = await request(server)
|
||||||
.get(`/stream/track/${trackId}`)
|
.get(`/stream/track/${trackId}`)
|
||||||
.set("bonob-token", authToken)
|
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
|
||||||
.set("Range", "4000-5000");
|
.set("Range", "4000-5000");
|
||||||
|
|
||||||
console.log("testing finished watiting");
|
|
||||||
|
|
||||||
expect(res.status).toEqual(stream.status);
|
expect(res.status).toEqual(stream.status);
|
||||||
expect(res.header["content-type"]).toEqual(
|
expect(res.header["content-type"]).toEqual(
|
||||||
stream.headers["content-type"]
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import X2JS from "x2js";
|
|||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
|
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 { bonobService, SONOS_DISABLED } from "../src/sonos";
|
||||||
import {
|
import {
|
||||||
STRINGS_ROUTE,
|
STRINGS_ROUTE,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||||
import supersoap from "./supersoap";
|
import supersoap from "./supersoap";
|
||||||
import { AuthSuccess } from "../src/music_service";
|
import { AuthSuccess } from "../src/music_service";
|
||||||
|
import { AccessTokens } from "../src/access_tokens";
|
||||||
|
|
||||||
describe("service config", () => {
|
describe("service config", () => {
|
||||||
describe("strings.xml", () => {
|
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", () => {
|
describe("api", () => {
|
||||||
const rootUrl = "http://localhost:1234";
|
const rootUrl = "http://localhost:1234";
|
||||||
const service = bonobService("test-api", 133, rootUrl, "AppLink");
|
const service = bonobService("test-api", 133, rootUrl, "AppLink");
|
||||||
const musicService = new InMemoryMusicService();
|
const musicService = new InMemoryMusicService();
|
||||||
const linkCodes = new InMemoryLinkCodes();
|
const linkCodes = new InMemoryLinkCodes();
|
||||||
|
const accessTokens = new Base64AccessTokens();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
musicService.clear();
|
musicService.clear();
|
||||||
@@ -101,7 +112,8 @@ describe("api", () => {
|
|||||||
service,
|
service,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
linkCodes,
|
||||||
|
accessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
describe(LOGIN_ROUTE, () => {
|
describe(LOGIN_ROUTE, () => {
|
||||||
@@ -174,7 +186,8 @@ describe("api", () => {
|
|||||||
service,
|
service,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
musicService,
|
musicService,
|
||||||
(mockLinkCodes as unknown) as LinkCodes
|
(mockLinkCodes as unknown) as LinkCodes,
|
||||||
|
accessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
it("should do something", async () => {
|
it("should do something", async () => {
|
||||||
@@ -211,7 +224,8 @@ describe("api", () => {
|
|||||||
service,
|
service,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
linkCodes,
|
||||||
|
accessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("when there is a linkCode association", () => {
|
describe("when there is a linkCode association", () => {
|
||||||
@@ -278,7 +292,8 @@ describe("api", () => {
|
|||||||
service,
|
service,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
linkCodes,
|
||||||
|
accessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("when no credentials header provided", () => {
|
describe("when no credentials header provided", () => {
|
||||||
@@ -452,6 +467,7 @@ describe("api", () => {
|
|||||||
itemType: "album",
|
itemType: "album",
|
||||||
id: `album:${it.id}`,
|
id: `album:${it.id}`,
|
||||||
title: it.name,
|
title: it.name,
|
||||||
|
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
|
||||||
})),
|
})),
|
||||||
index: 0,
|
index: 0,
|
||||||
total: artistWithManyAlbums.albums.length,
|
total: artistWithManyAlbums.albums.length,
|
||||||
@@ -476,6 +492,7 @@ describe("api", () => {
|
|||||||
itemType: "album",
|
itemType: "album",
|
||||||
id: `album:${it.id}`,
|
id: `album:${it.id}`,
|
||||||
title: it.name,
|
title: it.name,
|
||||||
|
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
|
||||||
})),
|
})),
|
||||||
index: 2,
|
index: 2,
|
||||||
total: artistWithManyAlbums.albums.length,
|
total: artistWithManyAlbums.albums.length,
|
||||||
@@ -605,6 +622,7 @@ describe("api", () => {
|
|||||||
itemType: "album",
|
itemType: "album",
|
||||||
id: `album:${it.id}`,
|
id: `album:${it.id}`,
|
||||||
title: it.name,
|
title: it.name,
|
||||||
|
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
|
||||||
})),
|
})),
|
||||||
index: 0,
|
index: 0,
|
||||||
total: 6,
|
total: 6,
|
||||||
@@ -630,6 +648,7 @@ describe("api", () => {
|
|||||||
itemType: "album",
|
itemType: "album",
|
||||||
id: `album:${it.id}`,
|
id: `album:${it.id}`,
|
||||||
title: it.name,
|
title: it.name,
|
||||||
|
albumArtURI: `${rootUrl}/album/${it.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessTokens.mint(token.authToken)}`,
|
||||||
})),
|
})),
|
||||||
index: 2,
|
index: 2,
|
||||||
total: 6,
|
total: 6,
|
||||||
@@ -736,12 +755,17 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getMediaURI", () => {
|
describe("getMediaURI", () => {
|
||||||
|
const accessTokenMint = jest.fn();
|
||||||
|
|
||||||
const server = makeServer(
|
const server = makeServer(
|
||||||
SONOS_DISABLED,
|
SONOS_DISABLED,
|
||||||
service,
|
service,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
linkCodes,
|
||||||
|
({
|
||||||
|
mint: accessTokenMint,
|
||||||
|
} as unknown) as AccessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("when no credentials header provided", () => {
|
describe("when no credentials header provided", () => {
|
||||||
@@ -797,6 +821,7 @@ describe("api", () => {
|
|||||||
const password = "validPassword";
|
const password = "validPassword";
|
||||||
let token: AuthSuccess;
|
let token: AuthSuccess;
|
||||||
let ws: Client;
|
let ws: Client;
|
||||||
|
const accessToken = "temporaryAccessToken";
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
musicService.hasUser({ username, password });
|
musicService.hasUser({ username, password });
|
||||||
@@ -809,6 +834,8 @@ describe("api", () => {
|
|||||||
httpClient: supersoap(server, rootUrl),
|
httpClient: supersoap(server, rootUrl),
|
||||||
});
|
});
|
||||||
ws.addSoapHeader({ credentials: someCredentials(token.authToken) });
|
ws.addSoapHeader({ credentials: someCredentials(token.authToken) });
|
||||||
|
|
||||||
|
accessTokenMint.mockReturnValue(accessToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for a URI to stream a track", () => {
|
describe("asking for a URI to stream a track", () => {
|
||||||
@@ -821,8 +848,8 @@ describe("api", () => {
|
|||||||
expect(root[0]).toEqual({
|
expect(root[0]).toEqual({
|
||||||
getMediaURIResult: `${rootUrl}/stream/track/${trackId}`,
|
getMediaURIResult: `${rootUrl}/stream/track/${trackId}`,
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
header: "bonob-token",
|
header: BONOB_ACCESS_TOKEN_HEADER,
|
||||||
value: token.authToken,
|
value: accessToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -836,7 +863,8 @@ describe("api", () => {
|
|||||||
service,
|
service,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
musicService,
|
musicService,
|
||||||
linkCodes
|
linkCodes,
|
||||||
|
accessTokens
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("when no credentials header provided", () => {
|
describe("when no credentials header provided", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user