diff --git a/src/music_service.ts b/src/music_service.ts index 53c4eec..67fcdd3 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -113,6 +113,11 @@ export type Stream = { data: Buffer; }; +export type CoverArt = { + contentType: string; + data: Buffer; +} + export const range = (size: number) => [...Array(size).keys()]; export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] => @@ -140,4 +145,5 @@ export interface MusicLibrary { trackId: string; range: string | undefined; }): Promise; + coverArt(id: string, size?: number): Promise; } diff --git a/src/navidrome.ts b/src/navidrome.ts index b388ec0..6296b18 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -450,6 +450,21 @@ export class Navidrome implements MusicService { }, 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); diff --git a/src/server.ts b/src/server.ts index bc92e87..10e9da1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,15 +11,18 @@ import { } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; -// import logger from "./logger"; import bindSmapiSoapServiceToExpress from "./smapi"; +import { AccessTokens, ExpiringAccessTokens } from "./access_tokens"; + +export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; function server( sonos: Sonos, bonobService: Service, webAddress: string | "http://localhost:4534", musicService: MusicService, - linkCodes: LinkCodes = new InMemoryLinkCodes() + linkCodes: LinkCodes = new InMemoryLinkCodes(), + accessTokens: AccessTokens = new ExpiringAccessTokens() ): Express { const app = express(); @@ -115,47 +118,51 @@ function server( app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; - const token = req.headers["bonob-token"] as string; - return musicService - .login(token) - .then((it) => - it.stream({ trackId: id, range: req.headers["range"] || undefined }) - ) - .then((stream) => { - res.status(stream.status); - Object.entries(stream.headers).forEach(([header, value]) => - res.setHeader(header, value) - ); - res.send(stream.data); - }); + 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 + .login(authToken) + .then((it) => + it.stream({ trackId: id, range: req.headers["range"] || undefined }) + ) + .then((stream) => { + res.status(stream.status); + Object.entries(stream.headers).forEach(([header, value]) => + res.setHeader(header, value) + ); + res.send(stream.data); + }); + } }); - // app.get("/album/:albumId/art", (req, res) => { - // console.log(`Trying to load image for ${req.params["albumId"]}, token ${JSON.stringify(req.cookies)}`) - // const authToken = req.headers["X-AuthToken"]! as string; - // const albumId = req.params["albumId"]!; - // musicService - // .login(authToken) - // // .then((it) => it.artist(artistId)) - // // .then(artist => artist.image.small) - // .then((url) => { - // if (url) { - // console.log(`${albumId} sending 307 -> ${url}`) - // // res.setHeader("Location", url); - // res.status(307).send(); - // } else { - // console.log(`${albumId} sending 404`) - // res.status(404).send(); - // } - // }); - // }); + app.get("/album/:albumId/art", (req, res) => { + const authToken = accessTokens.authTokenFor( + req.query[BONOB_ACCESS_TOKEN_HEADER] as string + ); + if (!authToken) { + return res.status(401).send(); + } else { + return musicService + .login(authToken) + .then((it) => it.coverArt(req.params["albumId"]!, 200)) + .then((coverArt) => { + res.status(200); + res.setHeader("content-type", coverArt.contentType); + res.send(coverArt.data); + }); + } + }); bindSmapiSoapServiceToExpress( app, SOAP_PATH, webAddress, linkCodes, - musicService + musicService, + accessTokens ); return app; diff --git a/src/smapi.ts b/src/smapi.ts index af34b3b..b30e9de 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -13,6 +13,8 @@ import { slice2, Track, } from "./music_service"; +import { AccessTokens } from "./access_tokens"; +import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; export const LOGIN_ROUTE = "/login"; export const SOAP_PATH = "/ws/sonos"; @@ -193,16 +195,15 @@ const genre = (genre: string) => ({ title: genre, }); -const album = (album: AlbumSummary) => ({ +const album = ( + webAddress: string, + accessToken: string, + album: AlbumSummary +) => ({ itemType: "album", id: `album:${album.id}`, title: album.name, - // albumArtURI: { - // attributes: { - // requiresAuthentication: "true" - // }, - // $value: `${webAddress}/album/${album.id}/art` - // } + albumArtURI: `${webAddress}/album/${album.id}/art?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`, }); const track = (track: Track) => ({ @@ -235,7 +236,8 @@ function bindSmapiSoapServiceToExpress( soapPath: string, webAddress: string, linkCodes: LinkCodes, - musicService: MusicService + musicService: MusicService, + accessTokens: AccessTokens ) { const sonosSoap = new SonosSoap(webAddress, linkCodes); const soapyService = listen( @@ -276,8 +278,10 @@ function bindSmapiSoapServiceToExpress( getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`, httpHeaders: [ { - header: "bonob-token", - value: headers?.credentials?.loginToken.token, + header: BONOB_ACCESS_TOKEN_HEADER, + value: accessTokens.mint( + headers?.credentials?.loginToken.token + ), }, ], }; @@ -330,16 +334,15 @@ function bindSmapiSoapServiceToExpress( }, }; } - const login = await musicService - .login(headers.credentials.loginToken.token) - .catch((_) => { - throw { - Fault: { - faultcode: "Client.LoginUnauthorized", - faultstring: "Credentials not found...", - }, - }; - }); + const authToken = headers.credentials.loginToken.token; + const login = await musicService.login(authToken).catch((_) => { + throw { + Fault: { + faultcode: "Client.LoginUnauthorized", + faultstring: "Credentials not found...", + }, + }; + }); const musicLibrary = login as MusicLibrary; @@ -372,13 +375,16 @@ function bindSmapiSoapServiceToExpress( }) ); case "albums": - return await musicLibrary.albums(paging).then((result) => - getMetadataResult({ - mediaCollection: result.results.map(album), + return await musicLibrary.albums(paging).then((result) => { + const accessToken = accessTokens.mint(authToken); + return getMetadataResult({ + mediaCollection: result.results.map((it) => + album(webAddress, accessToken, it) + ), index: paging._index, total: result.total, - }) - ); + }); + }); case "genres": return await musicLibrary .genres() @@ -395,13 +401,16 @@ function bindSmapiSoapServiceToExpress( .artist(typeId!) .then((artist) => artist.albums) .then(slice2(paging)) - .then(([page, total]) => - getMetadataResult({ - mediaCollection: page.map(album), + .then(([page, total]) => { + const accessToken = accessTokens.mint(authToken); + return getMetadataResult({ + mediaCollection: page.map((it) => + album(webAddress, accessToken, it) + ), index: paging._index, total, - }) - ); + }); + }); case "album": return await musicLibrary .tracks(typeId!) diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 1133d9a..3c45f27 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -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}`) }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 9ab02eb..46c87f4 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -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", + }); + }); + }); + }); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 7dd2d99..cf59ef6 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -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); + }); + }); + }); + }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index ffc1bfc..a060b1a 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -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", () => {