Ability to search by artist, album, track

This commit is contained in:
simojenki
2021-04-20 13:21:58 +10:00
parent 759592767f
commit d3d83df03c
12 changed files with 903 additions and 130 deletions

View File

@@ -18,7 +18,8 @@ Currently only a single integration allowing Navidrome to be registered with son
- Discovery of sonos devices using seed IP address - Discovery of sonos devices using seed IP address
- Auto register bonob service with sonos system - Auto register bonob service with sonos system
- Multiple registrations within a single household. - Multiple registrations within a single household.
- Transcoding performed by Navidrome with specific player for bonob/sonos - Transcoding performed by Navidrome with specific player for bonob/sonos, customisable by mimeType
- Ability to search by Album, Artist, Track
## Running ## Running
@@ -77,6 +78,5 @@ BONOB_STREAM_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom
## TODO ## TODO
- Search
- Artist Radio - Artist Radio
- Playlist support - Playlist support

View File

@@ -1,13 +1,10 @@
import dayjs, { Dayjs } from "dayjs"; import { Dayjs } from "dayjs";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import crypto from "crypto"; import crypto from "crypto";
import { Encryption } from "./encryption"; import { Encryption } from "./encryption";
import logger from "./logger"; import logger from "./logger";
import { Clock, SystemClock } from "./clock";
export interface Clock {
now(): Dayjs;
}
type AccessToken = { type AccessToken = {
value: string; value: string;
@@ -24,7 +21,7 @@ export class ExpiringAccessTokens implements AccessTokens {
tokens = new Map<string, AccessToken>(); tokens = new Map<string, AccessToken>();
clock: Clock; clock: Clock;
constructor(clock: Clock = { now: () => dayjs() }) { constructor(clock: Clock = SystemClock) {
this.clock = clock; this.clock = clock;
} }

7
src/clock.ts Normal file
View File

@@ -0,0 +1,7 @@
import dayjs, { Dayjs } from "dayjs";
export interface Clock {
now(): Dayjs;
}
export const SystemClock = { now: () => dayjs() };

View File

@@ -155,4 +155,7 @@ export interface MusicLibrary {
}): Promise<TrackStream>; }): Promise<TrackStream>;
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>; coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
scrobble(id: string): Promise<boolean> scrobble(id: string): Promise<boolean>
searchArtists(query: string): Promise<ArtistSummary[]>;
searchAlbums(query: string): Promise<AlbumSummary[]>;
searchTracks(query: string): Promise<Track[]>;
} }

View File

@@ -166,6 +166,14 @@ export type GetSongResponse = {
song: song; song: song;
}; };
export type Search3Response = SubsonicResponse & {
searchResult3: {
artist: artistSummary[];
album: album[];
song: song[];
};
};
export function isError( export function isError(
subsonicResponse: SubsonicResponse subsonicResponse: SubsonicResponse
): subsonicResponse is SubsonicError { ): subsonicResponse is SubsonicError {
@@ -270,9 +278,9 @@ export class Navidrome implements MusicService {
...config, ...config,
}) })
.then((response) => { .then((response) => {
if (response.status != 200 && response.status != 206) if (response.status != 200 && response.status != 206) {
throw `Navidrome failed with a ${response.status}`; throw `Navidrome failed with a ${response.status || "no!"} status`;
else return response; } else return response;
}); });
getJSON = async <T>( getJSON = async <T>(
@@ -290,6 +298,9 @@ export class Navidrome implements MusicService {
"subsonic-response.album.song", "subsonic-response.album.song",
"subsonic-response.genres.genre", "subsonic-response.genres.genre",
"subsonic-response.artistInfo.similarArtist", "subsonic-response.artistInfo.similarArtist",
"subsonic-response.searchResult3.artist",
"subsonic-response.searchResult3.album",
"subsonic-response.searchResult3.song",
], ],
}).xml2js(response.data) as SubconicEnvelope }).xml2js(response.data) as SubconicEnvelope
) )
@@ -405,6 +416,26 @@ export class Navidrome implements MusicService {
) )
); );
toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
albumList.map((album) => ({
id: album._id,
name: album._name,
year: album._year,
genre: maybeAsGenre(album._genre),
}));
search3 = (credentials: Credentials, q: any) =>
this.getJSON<Search3Response>(credentials, "/rest/search3", {
artistCount: 0,
albumCount: 0,
songCount: 0,
...q,
}).then((it) => ({
artists: it.searchResult3.artist || [],
albums: it.searchResult3.album || [],
songs: it.searchResult3.song || [],
}));
async login(token: string) { async login(token: string) {
const navidrome = this; const navidrome = this;
const credentials: Credentials = this.parseToken(token); const credentials: Credentials = this.parseToken(token);
@@ -428,14 +459,7 @@ export class Navidrome implements MusicService {
offset: q._index, offset: q._index,
}) })
.then((response) => response.albumList.album || []) .then((response) => response.albumList.album || [])
.then((albumList) => .then(navidrome.toAlbumSummary)
albumList.map((album) => ({
id: album._id,
name: album._name,
year: album._year,
genre: maybeAsGenre(album._genre),
}))
)
.then(slice2(q)) .then(slice2(q))
.then(([page, total]) => ({ .then(([page, total]) => ({
results: page, results: page,
@@ -555,6 +579,27 @@ export class Navidrome implements MusicService {
}) })
.then((_) => true) .then((_) => true)
.catch(() => false), .catch(() => false),
searchArtists: async (query: string) =>
navidrome
.search3(credentials, { query, artistCount: 20 })
.then(({ artists }) =>
artists.map((artist) => ({
id: artist._id,
name: artist._name,
}))
),
searchAlbums: async (query: string) =>
navidrome
.search3(credentials, { query, albumCount: 20 })
.then(({ albums }) => navidrome.toAlbumSummary(albums)),
searchTracks: async (query: string) =>
navidrome
.search3(credentials, { query, songCount: 20 })
.then(({ songs }) =>
Promise.all(
songs.map((it) => navidrome.getTrack(credentials, it._id))
)
),
}; };
return Promise.resolve(musicLibrary); return Promise.resolve(musicLibrary);

View File

@@ -15,6 +15,7 @@ import { MusicService, isSuccess } from "./music_service";
import bindSmapiSoapServiceToExpress from "./smapi"; import bindSmapiSoapServiceToExpress from "./smapi";
import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens";
import logger from "./logger"; import logger from "./logger";
import { Clock, SystemClock } from "./clock";
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
@@ -24,7 +25,8 @@ function server(
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 AccessTokenPerAuthToken() accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
clock: Clock = SystemClock
): Express { ): Express {
const app = express(); const app = express();
@@ -125,6 +127,15 @@ function server(
</imageSizeMap> </imageSizeMap>
</Match> </Match>
</PresentationMap> </PresentationMap>
<PresentationMap type="Search">
<Match>
<SearchCategories>
<Category id="artists"/>
<Category id="albums"/>
<Category id="tracks"/>
</SearchCategories>
</Match>
</PresentationMap>
</Presentation>`); </Presentation>`);
}); });
@@ -198,7 +209,8 @@ function server(
webAddress, webAddress,
linkCodes, linkCodes,
musicService, musicService,
accessTokens accessTokens,
clock
); );
return app; return app;

View File

@@ -18,6 +18,7 @@ import {
} from "./music_service"; } from "./music_service";
import { AccessTokens } from "./access_tokens"; import { AccessTokens } from "./access_tokens";
import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
import { Clock } from "./clock";
export const LOGIN_ROUTE = "/login"; export const LOGIN_ROUTE = "/login";
export const SOAP_PATH = "/ws/sonos"; export const SOAP_PATH = "/ws/sonos";
@@ -107,6 +108,26 @@ export function getMetadataResult(
}; };
} }
export type SearchResponse = {
searchResult: getMetadataResult;
};
export function searchResult(
result: Partial<getMetadataResult>
): SearchResponse {
const count =
(result?.mediaCollection?.length || 0) +
(result?.mediaMetadata?.length || 0);
return {
searchResult: {
count,
index: 0,
total: count,
...result,
},
};
}
class SonosSoap { class SonosSoap {
linkCodes: LinkCodes; linkCodes: LinkCodes;
webAddress: string; webAddress: string;
@@ -168,7 +189,7 @@ class SonosSoap {
} }
export type Container = { export type Container = {
itemType: "container"; itemType: "container" | "search";
id: string; id: string;
title: string; title: string;
}; };
@@ -185,6 +206,12 @@ const container = ({
title, title,
}); });
const search = ({ id, title }: { id: string; title: string }): Container => ({
itemType: "search",
id,
title,
});
const genre = (genre: Genre) => ({ const genre = (genre: Genre) => ({
itemType: "container", itemType: "container",
id: `genre:${genre.id}`, id: `genre:${genre.id}`,
@@ -235,10 +262,10 @@ export const track = (
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album), albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album),
artist: track.artist.name, artist: track.artist.name,
artistId: track.artist.id, artistId: track.artist.id,
duration: track.duration, duration: `${track.duration}`,
genre: track.album.genre?.name, genre: track.album.genre?.name,
genreId: track.album.genre?.id, genreId: track.album.genre?.id,
trackNumber: track.number, trackNumber: `${track.number}`,
}, },
}); });
@@ -300,7 +327,8 @@ function bindSmapiSoapServiceToExpress(
webAddress: string, webAddress: string,
linkCodes: LinkCodes, linkCodes: LinkCodes,
musicService: MusicService, musicService: MusicService,
accessTokens: AccessTokens accessTokens: AccessTokens,
clock: Clock
) { ) {
const sonosSoap = new SonosSoap(webAddress, linkCodes); const sonosSoap = new SonosSoap(webAddress, linkCodes);
const soapyService = listen( const soapyService = listen(
@@ -312,6 +340,13 @@ function bindSmapiSoapServiceToExpress(
getAppLink: () => sonosSoap.getAppLink(), getAppLink: () => sonosSoap.getAppLink(),
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
sonosSoap.getDeviceAuthToken({ linkCode }), sonosSoap.getDeviceAuthToken({ linkCode }),
getLastUpdate: () => ({
getLastUpdateResult: {
favorites: clock.now().unix(),
catalog: clock.now().unix(),
pollInterval: 120,
},
}),
getMediaURI: async ( getMediaURI: async (
{ id }: { id: string }, { id }: { id: string },
_, _,
@@ -339,6 +374,46 @@ function bindSmapiSoapServiceToExpress(
getMediaMetadataResult: track(webAddress, accessToken, it), getMediaMetadataResult: track(webAddress, accessToken, it),
})) }))
), ),
search: async (
{ id, term }: { id: string; term: string },
_,
headers?: SoapyHeaders
) =>
auth(musicService, accessTokens, id, headers).then(
async ({ musicLibrary, accessToken }) => {
switch (id) {
case "albums":
return musicLibrary.searchAlbums(term).then((it) =>
searchResult({
count: it.length,
mediaCollection: it.map((albumSummary) =>
album(webAddress, accessToken, albumSummary)
),
})
);
case "artists":
return musicLibrary.searchArtists(term).then((it) =>
searchResult({
count: it.length,
mediaCollection: it.map((artistSummary) =>
artist(webAddress, accessToken, artistSummary)
),
})
);
case "tracks":
return musicLibrary.searchTracks(term).then((it) =>
searchResult({
count: it.length,
mediaCollection: it.map((aTrack) =>
track(webAddress, accessToken, aTrack)
),
})
);
default:
throw `Unsupported search by:${id}`;
}
}
),
getExtendedMetadata: async ( getExtendedMetadata: async (
{ {
id, id,
@@ -436,6 +511,16 @@ function bindSmapiSoapServiceToExpress(
index: 0, index: 0,
total: 8, total: 8,
}); });
case "search":
return getMetadataResult({
mediaCollection: [
search({ id: "artists", title: "Artists" }),
search({ id: "albums", title: "Albums" }),
search({ id: "tracks", title: "Tracks" }),
],
index: 0,
total: 3,
});
case "artists": case "artists":
return musicLibrary.artists(paging).then((result) => { return musicLibrary.artists(paging).then((result) => {
return getMetadataResult({ return getMetadataResult({

View File

@@ -7,8 +7,8 @@ import logger from "./logger";
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi"; import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
import qs from "querystring" import qs from "querystring"
export const STRINGS_VERSION = "2"; export const PRESENTATION_AND_STRINGS_VERSION = "12";
export const PRESENTATION_MAP_VERSION = "7";
export type Capability = export type Capability =
| "search" | "search"
| "trFavorites" | "trFavorites"
@@ -19,7 +19,7 @@ export type Capability =
| "authorizationHeader"; | "authorizationHeader";
export const BONOB_CAPABILITIES: Capability[] = [ export const BONOB_CAPABILITIES: Capability[] = [
// "search", "search",
// "trFavorites", // "trFavorites",
// "alFavorites", // "alFavorites",
// "ucPlaylists", // "ucPlaylists",
@@ -59,11 +59,11 @@ export const bonobService = (
secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
strings: { strings: {
uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`, uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`,
version: STRINGS_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
presentation: { presentation: {
uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`, uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`,
version: PRESENTATION_MAP_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
pollInterval: 1200, pollInterval: 1200,
authType, authType,

View File

@@ -130,6 +130,9 @@ export class InMemoryMusicService implements MusicService {
scrobble: async (_: string) => { scrobble: async (_: string) => {
return Promise.resolve(true); return Promise.resolve(true);
}, },
searchArtists: async (_: string) => Promise.resolve([]),
searchAlbums: async (_: string) => Promise.resolve([]),
searchTracks: async (_: string) => Promise.resolve([]),
}); });
} }

View File

@@ -8,7 +8,7 @@ import {
BROWSER_HEADERS, BROWSER_HEADERS,
DODGY_IMAGE_NAME, DODGY_IMAGE_NAME,
asGenre, asGenre,
appendMimeTypeToClientFor appendMimeTypeToClientFor,
} from "../src/navidrome"; } from "../src/navidrome";
import encryption from "../src/encryption"; import encryption from "../src/encryption";
@@ -72,18 +72,27 @@ describe("appendMimeTypeToUserAgentFor", () => {
}); });
describe("when contains some mimeTypes", () => { describe("when contains some mimeTypes", () => {
const streamUserAgent = appendMimeTypeToClientFor(["audio/flac", "audio/ogg"]) const streamUserAgent = appendMimeTypeToClientFor([
"audio/flac",
"audio/ogg",
]);
describe("and the track mimeType is in the array", () => { describe("and the track mimeType is in the array", () => {
it("should return bonob+mimeType", () => { it("should return bonob+mimeType", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/flac"}))).toEqual("bonob+audio/flac") expect(streamUserAgent(aTrack({ mimeType: "audio/flac" }))).toEqual(
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg"}))).toEqual("bonob+audio/ogg") "bonob+audio/flac"
);
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg" }))).toEqual(
"bonob+audio/ogg"
);
}); });
}); });
describe("and the track mimeType is not in the array", () => { describe("and the track mimeType is not in the array", () => {
it("should return bonob", () => { it("should return bonob", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3"}))).toEqual("bonob") expect(streamUserAgent(aTrack({ mimeType: "audio/mp3" }))).toEqual(
"bonob"
);
}); });
}); });
}); });
@@ -94,7 +103,7 @@ const ok = (data: string) => ({
data, data,
}); });
const artistInfoXml = ( const getArtistInfoXml = (
artist: Artist artist: Artist
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artistInfo> <artistInfo>
@@ -162,14 +171,18 @@ const albumListXml = (
</albumList> </albumList>
</subsonic-response>`; </subsonic-response>`;
const artistXml = ( const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${
artist.name
}" albumCount="${artist.albums.length}" artistImageUrl="....">
${artist.albums.map((album) =>
albumXml(artist, album)
)}
</artist>`;
const getArtistXml = (
artist: Artist artist: Artist
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artist id="${artist.id}" name="${artist.name}" albumCount="${ ${artistXml(artist)}
artist.albums.length
}" artistImageUrl="....">
${artist.albums.map((album) => albumXml(artist, album))}
</artist>
</subsonic-response>`; </subsonic-response>`;
const genresXml = ( const genresXml = (
@@ -203,6 +216,32 @@ const getSongXml = (
)} )}
</subsonic-response>`; </subsonic-response>`;
export type ArtistWithAlbum = {
artist: Artist;
album: Album;
};
const searchResult3 = ({
artists,
albums,
tracks,
}: Partial<{
artists: Artist[];
albums: ArtistWithAlbum[];
tracks: Track[];
}>) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.41.1 (43bb0758)">
<searchResult3>
${(artists || []).map((it) =>
artistXml({
...it,
albums: [],
})
)}
${(albums || []).map((it) => albumXml(it.artist, it.album, []))}
${(tracks || []).map((it) => songXml(it))}
</searchResult3>
</subsonic-response>`;
const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`; const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></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>`; 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>`;
@@ -214,7 +253,11 @@ describe("Navidrome", () => {
const salt = "saltysalty"; const salt = "saltysalty";
const streamClientApplication = jest.fn(); const streamClientApplication = jest.fn();
const navidrome = new Navidrome(url, encryption("secret"), streamClientApplication); const navidrome = new Navidrome(
url,
encryption("secret"),
streamClientApplication
);
const mockedRandomString = (randomString as unknown) as jest.Mock; const mockedRandomString = (randomString as unknown) as jest.Mock;
const mockGET = jest.fn(); const mockGET = jest.fn();
@@ -356,10 +399,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
); );
}); });
@@ -419,10 +462,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
); );
}); });
@@ -482,10 +525,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
); );
}); });
@@ -545,10 +588,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
); );
}); });
@@ -603,10 +646,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
); );
}); });
@@ -655,10 +698,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
); );
}); });
@@ -705,10 +748,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
); );
}); });
@@ -1553,7 +1596,7 @@ describe("Navidrome", () => {
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => { it("should return undefined values", async () => {
const stream = { const stream = {
pipe: jest.fn() pipe: jest.fn(),
}; };
const streamResponse = { const streamResponse = {
@@ -1592,7 +1635,7 @@ describe("Navidrome", () => {
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => { it("should return undefined values", async () => {
const stream = { const stream = {
pipe: jest.fn() pipe: jest.fn(),
}; };
const streamResponse = { const streamResponse = {
@@ -1635,7 +1678,7 @@ describe("Navidrome", () => {
describe("navidrome returns a 200", () => { describe("navidrome returns a 200", () => {
it("should return the content", async () => { it("should return the content", async () => {
const stream = { const stream = {
pipe: jest.fn() pipe: jest.fn(),
}; };
const streamResponse = { const streamResponse = {
@@ -1712,7 +1755,7 @@ describe("Navidrome", () => {
return expect( return expect(
musicLibrary.stream({ trackId, range: undefined }) musicLibrary.stream({ trackId, range: undefined })
).rejects.toEqual(`Navidrome failed with a 400`); ).rejects.toEqual(`Navidrome failed with a 400 status`);
}); });
}); });
}); });
@@ -1720,7 +1763,7 @@ describe("Navidrome", () => {
describe("with range specified", () => { describe("with range specified", () => {
it("should send the range to navidrome", async () => { it("should send the range to navidrome", async () => {
const stream = { const stream = {
pipe: jest.fn() pipe: jest.fn(),
}; };
const range = "1000-2000"; const range = "1000-2000";
@@ -1780,7 +1823,7 @@ describe("Navidrome", () => {
it("should user the custom StreamUserAgent when calling navidrome", async () => { it("should user the custom StreamUserAgent when calling navidrome", async () => {
const clientApplication = `bonob-${uuid()}`; const clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication); streamClientApplication.mockReturnValue(clientApplication);
const streamResponse = { const streamResponse = {
status: 200, status: 200,
headers: { headers: {
@@ -1788,27 +1831,29 @@ describe("Navidrome", () => {
}, },
data: Buffer.from("the track", "ascii"), data: Buffer.from("the track", "ascii"),
}; };
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) .mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [track]))) Promise.resolve(ok(getAlbumXml(artist, album, [track])))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
await navidrome 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.stream({ trackId, range: undefined })); .then((it) => it.stream({ trackId, range: undefined }));
expect(streamClientApplication).toHaveBeenCalledWith(track); expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: { params: {
id: trackId, id: trackId,
...authParams, ...authParams,
c: clientApplication c: clientApplication,
}, },
headers: { headers: {
"User-Agent": "bonob", "User-Agent": "bonob",
@@ -1817,13 +1862,13 @@ describe("Navidrome", () => {
}); });
}); });
}); });
describe("when range specified", () => { describe("when range specified", () => {
it("should user the custom StreamUserAgent when calling navidrome", async () => { it("should user the custom StreamUserAgent when calling navidrome", async () => {
const range = "1000-2000"; const range = "1000-2000";
const clientApplication = `bonob-${uuid()}`; const clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication); streamClientApplication.mockReturnValue(clientApplication);
const streamResponse = { const streamResponse = {
status: 200, status: 200,
headers: { headers: {
@@ -1831,27 +1876,29 @@ describe("Navidrome", () => {
}, },
data: Buffer.from("the track", "ascii"), data: Buffer.from("the track", "ascii"),
}; };
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) .mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [track]))) Promise.resolve(ok(getAlbumXml(artist, album, [track])))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
await navidrome 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.stream({ trackId, range })); .then((it) => it.stream({ trackId, range }));
expect(streamClientApplication).toHaveBeenCalledWith(track); expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: { params: {
id: trackId, id: trackId,
...authParams, ...authParams,
c: clientApplication c: clientApplication,
}, },
headers: { headers: {
"User-Agent": "bonob", "User-Agent": "bonob",
@@ -1968,10 +2015,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2035,10 +2082,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2113,10 +2160,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2180,10 +2227,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2256,10 +2303,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2335,10 +2382,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2403,10 +2450,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2482,10 +2529,10 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist))) Promise.resolve(ok(getArtistXml(artist)))
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2582,4 +2629,369 @@ describe("Navidrome", () => {
}); });
}); });
}); });
describe("searchArtists", () => {
describe("when there is 1 search results", () => {
it("should return true", async () => {
const artist1 = anArtist({ name: "foo woo" });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ artists: [artist1] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchArtists("foo"));
expect(result).toEqual([artistToArtistSummary(artist1)]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
artistCount: 20,
albumCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are many search results", () => {
it("should return true", async () => {
const artist1 = anArtist({ name: "foo woo" });
const artist2 = anArtist({ name: "foo choo" });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ artists: [artist1, artist2] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchArtists("foo"));
expect(result).toEqual([
artistToArtistSummary(artist1),
artistToArtistSummary(artist2),
]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
artistCount: 20,
albumCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are no search results", () => {
it("should return []", async () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ artists: [] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchArtists("foo"));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
artistCount: 20,
albumCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
});
describe("searchAlbums", () => {
describe("when there is 1 search results", () => {
it("should return true", async () => {
const artist = anArtist({ name: "#1" });
const album = anAlbum({
name: "foo woo",
genre: { id: "pop", name: "pop" },
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ albums: [{ artist, album }] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchAlbums("foo"));
expect(result).toEqual([albumToAlbumSummary(album)]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
albumCount: 20,
artistCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are many search results", () => {
it("should return true", async () => {
const artist1 = anArtist({ name: "artist1" });
const album1 = anAlbum({
name: "album1",
genre: { id: "pop", name: "pop" },
});
const artist2 = anArtist({ name: "artist2" });
const album2 = anAlbum({
name: "album2",
genre: { id: "pop", name: "pop" },
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(
ok(
searchResult3({
albums: [
{ artist: artist1, album: album1 },
{ artist: artist2, album: album2 },
],
})
)
)
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchAlbums("moo"));
expect(result).toEqual([
albumToAlbumSummary(album1),
albumToAlbumSummary(album2),
]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "moo",
albumCount: 20,
artistCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are no search results", () => {
it("should return []", async () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ albums: [] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchAlbums("foo"));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
albumCount: 20,
artistCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
});
describe("searchSongs", () => {
describe("when there is 1 search results", () => {
it("should return true", async () => {
const pop = asGenre("Pop");
const album = anAlbum({ id: "album1", name: "Burnin", genre: pop });
const artist = anArtist({
id: "artist1",
name: "Bob Marley",
albums: [album],
});
const track = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album),
genre: pop,
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ tracks: [track] })))
)
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchTracks("foo"));
expect(result).toEqual([track]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
songCount: 20,
artistCount: 0,
albumCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are many search results", () => {
it("should return true", async () => {
const pop = asGenre("Pop");
const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop });
const artist1 = anArtist({
id: "artist1",
name: "Bob Marley",
albums: [album1],
});
const track1 = aTrack({
id: "track1",
artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1),
genre: pop,
});
const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop });
const artist2 = anArtist({
id: "artist2",
name: "Jane Marley",
albums: [album2],
});
const track2 = aTrack({
id: "track2",
artist: artistToArtistSummary(artist2),
album: albumToAlbumSummary(album2),
genre: pop,
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(
ok(
searchResult3({
tracks: [track1, track2],
})
)
)
)
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track1))))
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track2))))
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist1, album1, [])))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist2, album2, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchTracks("moo"));
expect(result).toEqual([track1, track2]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "moo",
songCount: 20,
artistCount: 0,
albumCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are no search results", () => {
it("should return []", async () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ tracks: [] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchTracks("foo"));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
songCount: 20,
artistCount: 0,
albumCount: 0,
...authParams,
},
headers,
});
});
});
});
}); });

View File

@@ -17,9 +17,11 @@ import {
PRESENTATION_MAP_ROUTE, PRESENTATION_MAP_ROUTE,
SONOS_RECOMMENDED_IMAGE_SIZES, SONOS_RECOMMENDED_IMAGE_SIZES,
track, track,
artist,
album, album,
defaultAlbumArtURI, defaultAlbumArtURI,
defaultArtistArtURI, defaultArtistArtURI,
searchResult,
} from "../src/smapi"; } from "../src/smapi";
import { import {
@@ -36,8 +38,13 @@ import {
} from "./builders"; } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap"; import supersoap from "./supersoap";
import { artistToArtistSummary, MusicService } from "../src/music_service"; import {
albumToAlbumSummary,
artistToArtistSummary,
MusicService,
} from "../src/music_service";
import { AccessTokens } from "../src/access_tokens"; import { AccessTokens } from "../src/access_tokens";
import dayjs from "dayjs";
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
@@ -282,11 +289,17 @@ describe("api", () => {
albums: jest.fn(), albums: jest.fn(),
tracks: jest.fn(), tracks: jest.fn(),
track: jest.fn(), track: jest.fn(),
searchArtists: jest.fn(),
searchAlbums: jest.fn(),
searchTracks: jest.fn(),
}; };
const accessTokens = { const accessTokens = {
mint: jest.fn(), mint: jest.fn(),
authTokenFor: jest.fn(), authTokenFor: jest.fn(),
}; };
const clock = {
now: jest.fn(),
};
const server = makeServer( const server = makeServer(
SONOS_DISABLED, SONOS_DISABLED,
@@ -294,7 +307,8 @@ describe("api", () => {
rootUrl, rootUrl,
(musicService as unknown) as MusicService, (musicService as unknown) as MusicService,
(linkCodes as unknown) as LinkCodes, (linkCodes as unknown) as LinkCodes,
(accessTokens as unknown) as AccessTokens (accessTokens as unknown) as AccessTokens,
clock
); );
beforeEach(() => { beforeEach(() => {
@@ -466,6 +480,179 @@ describe("api", () => {
}); });
}); });
describe("getLastUpdate", () => {
it("should return a result with some timestamps", async () => {
const now = dayjs();
clock.now.mockReturnValue(now);
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
const result = await ws.getLastUpdateAsync({});
expect(result[0]).toEqual({
getLastUpdateResult: {
favorites: `${now.unix()}`,
catalog: `${now.unix()}`,
pollInterval: 120,
},
});
});
});
describe("search", () => {
describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getMetadataAsync({ id: "search", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
});
});
});
});
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("fail!");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials("someAuthToken") });
await ws
.getMetadataAsync({ id: "search", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
});
});
});
});
describe("when valid credentials are provided", () => {
const authToken = `authToken-${uuid()}`;
const accessToken = `accessToken-${uuid()}`;
let ws: Client;
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(authToken) });
});
describe("searching for albums", () => {
const album1 = anAlbum();
const album2 = anAlbum();
const albums = [album1, album2];
beforeEach(() => {
musicLibrary.searchAlbums.mockResolvedValue([
albumToAlbumSummary(album1),
albumToAlbumSummary(album2),
]);
});
it("should return the albums", async () => {
const term = "whoop";
const result = await ws.searchAsync({
id: "albums",
term,
});
expect(result[0]).toEqual(
searchResult({
mediaCollection: albums.map((it) =>
album(rootUrl, accessToken, albumToAlbumSummary(it))
),
index: 0,
total: 2,
})
);
expect(musicLibrary.searchAlbums).toHaveBeenCalledWith(term);
});
});
describe("searching for artists", () => {
const artist1 = anArtist();
const artist2 = anArtist();
const artists = [artist1, artist2];
beforeEach(() => {
musicLibrary.searchArtists.mockResolvedValue([
artistToArtistSummary(artist1),
artistToArtistSummary(artist2),
]);
});
it("should return the artists", async () => {
const term = "whoopie";
const result = await ws.searchAsync({
id: "artists",
term,
});
expect(result[0]).toEqual(
searchResult({
mediaCollection: artists.map((it) =>
artist(rootUrl, accessToken, artistToArtistSummary(it))
),
index: 0,
total: 2,
})
);
expect(musicLibrary.searchArtists).toHaveBeenCalledWith(term);
});
});
describe("searching for tracks", () => {
const track1 = aTrack();
const track2 = aTrack();
const tracks = [track1, track2];
beforeEach(() => {
musicLibrary.searchTracks.mockResolvedValue([track1, track2]);
});
it.only("should return the tracks", async () => {
const term = "whoopie";
const result = await ws.searchAsync({
id: "tracks",
term,
});
expect(result[0]).toEqual(
searchResult({
mediaCollection: tracks.map((it) => track(rootUrl, accessToken, it)),
index: 0,
total: 2,
})
);
expect(musicLibrary.searchTracks).toHaveBeenCalledWith(term);
});
});
});
});
describe("getMetadata", () => { describe("getMetadata", () => {
describe("when no credentials header provided", () => { describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => { it("should return a fault of LoginUnsupported", async () => {
@@ -570,6 +757,27 @@ describe("api", () => {
}); });
}); });
describe("asking for the search container", () => {
it("should return it", async () => {
const root = await ws.getMetadataAsync({
id: "search",
index: 0,
count: 100,
});
expect(root[0]).toEqual(
getMetadataResult({
mediaCollection: [
{ itemType: "search", id: "artists", title: "Artists" },
{ itemType: "search", id: "albums", title: "Albums" },
{ itemType: "search", id: "tracks", title: "Tracks" },
],
index: 0,
total: 3,
})
);
});
});
describe("asking for a genres", () => { describe("asking for a genres", () => {
const expectedGenres = [POP, PUNK, ROCK, TRIP_HOP]; const expectedGenres = [POP, PUNK, ROCK, TRIP_HOP];
@@ -984,7 +1192,7 @@ describe("api", () => {
_count: paging.count, _count: paging.count,
}); });
}); });
}); });
describe("asking for recently played albums", () => { describe("asking for recently played albums", () => {
const recentlyPlayed = [rock2, rock1, pop2]; const recentlyPlayed = [rock2, rock1, pop2];
@@ -1027,7 +1235,7 @@ describe("api", () => {
_count: paging.count, _count: paging.count,
}); });
}); });
}); });
describe("asking for most played albums", () => { describe("asking for most played albums", () => {
const mostPlayed = [rock2, rock1, pop2]; const mostPlayed = [rock2, rock1, pop2];
@@ -1070,8 +1278,8 @@ describe("api", () => {
_count: paging.count, _count: paging.count,
}); });
}); });
}); });
describe("asking for recently added albums", () => { describe("asking for recently added albums", () => {
const recentlyAdded = [pop4, pop3, pop2]; const recentlyAdded = [pop4, pop3, pop2];
@@ -1113,7 +1321,7 @@ describe("api", () => {
_count: paging.count, _count: paging.count,
}); });
}); });
}); });
describe("asking for all albums", () => { describe("asking for all albums", () => {
beforeEach(() => { beforeEach(() => {
@@ -1377,7 +1585,7 @@ describe("api", () => {
describe("when invalid credentials are provided", () => { describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => { it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("booom!") musicService.login.mockRejectedValue("booom!");
const ws = await createClientAsync(`${service.uri}?wsdl`, { const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri, endpoint: service.uri,
@@ -1399,7 +1607,7 @@ describe("api", () => {
describe("when valid credentials are provided", () => { describe("when valid credentials are provided", () => {
let ws: Client; let ws: Client;
const authToken = `authToken-${uuid()}` const authToken = `authToken-${uuid()}`;
const accessToken = `accessToken-${uuid()}`; const accessToken = `accessToken-${uuid()}`;
beforeEach(async () => { beforeEach(async () => {
@@ -1425,7 +1633,7 @@ describe("api", () => {
}); });
beforeEach(() => { beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist) musicLibrary.artist.mockResolvedValue(artist);
}); });
describe("when all albums fit on a page", () => { describe("when all albums fit on a page", () => {
@@ -1434,18 +1642,20 @@ describe("api", () => {
index: 0, index: 0,
count: 100, count: 100,
}; };
const root = await ws.getExtendedMetadataAsync({ const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`, id: `artist:${artist.id}`,
...paging ...paging,
}); });
expect(root[0]).toEqual({ expect(root[0]).toEqual({
getExtendedMetadataResult: { getExtendedMetadataResult: {
count: "3", count: "3",
index: "0", index: "0",
total: "3", total: "3",
mediaCollection: artist.albums.map(it => album(rootUrl, accessToken, it)) mediaCollection: artist.albums.map((it) =>
album(rootUrl, accessToken, it)
),
}, },
}); });
}); });
@@ -1457,22 +1667,24 @@ describe("api", () => {
index: 1, index: 1,
count: 2, count: 2,
}; };
const root = await ws.getExtendedMetadataAsync({ const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`, id: `artist:${artist.id}`,
...paging ...paging,
}); });
expect(root[0]).toEqual({ expect(root[0]).toEqual({
getExtendedMetadataResult: { getExtendedMetadataResult: {
count: "2", count: "2",
index: "1", index: "1",
total: "3", total: "3",
mediaCollection: [album2, album3].map(it => album(rootUrl, accessToken, it)) mediaCollection: [album2, album3].map((it) =>
album(rootUrl, accessToken, it)
),
}, },
}); });
}); });
}); });
}); });
describe("when it has similar artists", () => { describe("when it has similar artists", () => {
@@ -1485,7 +1697,7 @@ describe("api", () => {
}); });
beforeEach(() => { beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist) musicLibrary.artist.mockResolvedValue(artist);
}); });
it("should return a RELATED_ARTISTS browse option", async () => { it("should return a RELATED_ARTISTS browse option", async () => {
@@ -1496,7 +1708,7 @@ describe("api", () => {
const root = await ws.getExtendedMetadataAsync({ const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`, id: `artist:${artist.id}`,
...paging ...paging,
}); });
expect(root[0]).toEqual({ expect(root[0]).toEqual({
@@ -1523,7 +1735,7 @@ describe("api", () => {
}); });
beforeEach(() => { beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist) musicLibrary.artist.mockResolvedValue(artist);
}); });
it("should not return a RELATED_ARTISTS browse option", async () => { it("should not return a RELATED_ARTISTS browse option", async () => {
@@ -1568,7 +1780,7 @@ describe("api", () => {
describe("when invalid credentials are provided", () => { describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => { it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("Credentials not found") musicService.login.mockRejectedValue("Credentials not found");
const ws = await createClientAsync(`${service.uri}?wsdl`, { const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri, endpoint: service.uri,
@@ -1589,12 +1801,12 @@ describe("api", () => {
}); });
describe("when valid credentials are provided", () => { describe("when valid credentials are provided", () => {
const authToken = `authToken-${uuid()}` const authToken = `authToken-${uuid()}`;
let ws: Client; let ws: Client;
const accessToken = `temporaryAccessToken-${uuid()}`; const accessToken = `temporaryAccessToken-${uuid()}`;
beforeEach(async () => { beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary) musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken); accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, { ws = await createClientAsync(`${service.uri}?wsdl`, {
@@ -1656,7 +1868,9 @@ describe("api", () => {
httpClient: supersoap(server, rootUrl), httpClient: supersoap(server, rootUrl),
}); });
ws.addSoapHeader({ credentials: someCredentials("some invalid token") }); ws.addSoapHeader({
credentials: someCredentials("some invalid token"),
});
await ws await ws
.getMediaMetadataAsync({ id: "track:123" }) .getMediaMetadataAsync({ id: "track:123" })
.then(() => fail("shouldnt get here")) .then(() => fail("shouldnt get here"))
@@ -1695,11 +1909,7 @@ describe("api", () => {
}); });
expect(root[0]).toEqual({ expect(root[0]).toEqual({
getMediaMetadataResult: track( getMediaMetadataResult: track(rootUrl, accessToken, someTrack),
rootUrl,
accessToken,
someTrack
),
}); });
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken);

View File

@@ -20,8 +20,7 @@ import sonos, {
asCustomdForm, asCustomdForm,
bonobService, bonobService,
Service, Service,
STRINGS_VERSION, PRESENTATION_AND_STRINGS_VERSION,
PRESENTATION_MAP_VERSION,
BONOB_CAPABILITIES, BONOB_CAPABILITIES,
} from "../src/sonos"; } from "../src/sonos";
@@ -119,11 +118,11 @@ describe("sonos", () => {
secureUri: `http://bonob.example.com/ws/sonos`, secureUri: `http://bonob.example.com/ws/sonos`,
strings: { strings: {
uri: `http://bonob.example.com/sonos/strings.xml`, uri: `http://bonob.example.com/sonos/strings.xml`,
version: STRINGS_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
presentation: { presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`, uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_MAP_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
pollInterval: 1200, pollInterval: 1200,
authType: "AppLink", authType: "AppLink",
@@ -142,11 +141,11 @@ describe("sonos", () => {
secureUri: `http://bonob.example.com/ws/sonos`, secureUri: `http://bonob.example.com/ws/sonos`,
strings: { strings: {
uri: `http://bonob.example.com/sonos/strings.xml`, uri: `http://bonob.example.com/sonos/strings.xml`,
version: STRINGS_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
presentation: { presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`, uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_MAP_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
pollInterval: 1200, pollInterval: 1200,
authType: "AppLink", authType: "AppLink",
@@ -165,11 +164,11 @@ describe("sonos", () => {
secureUri: `http://bonob.example.com/ws/sonos`, secureUri: `http://bonob.example.com/ws/sonos`,
strings: { strings: {
uri: `http://bonob.example.com/sonos/strings.xml`, uri: `http://bonob.example.com/sonos/strings.xml`,
version: STRINGS_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
presentation: { presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`, uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_MAP_VERSION, version: PRESENTATION_AND_STRINGS_VERSION,
}, },
pollInterval: 1200, pollInterval: 1200,
authType: "DeviceLink", authType: "DeviceLink",